diff --git a/.gitignore b/.gitignore index 0b3495c..4a44c74 100644 --- a/.gitignore +++ b/.gitignore @@ -116,4 +116,6 @@ build/ PLAN.md macos-cache-cleanup.sh xcuserdata/ -*.xcuserstate \ No newline at end of file +*.xcuserstate +opencode.json +.agents diff --git a/MacOSCleaner/App/MacOSCleanerApp.swift b/MacOSCleaner/App/MacOSCleanerApp.swift index 6217d0f..d78fbc2 100644 --- a/MacOSCleaner/App/MacOSCleanerApp.swift +++ b/MacOSCleaner/App/MacOSCleanerApp.swift @@ -22,11 +22,8 @@ struct MacOSCleanerApp: App { let engine = CleanupEngine(commandRunner: commandRunner) self.cleanupViewModel = CleanupViewModel(engine: engine, journal: journal, settings: appSettings) - // Request permissions at startup - let manager = permissionsManager - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - manager.showGuidanceIfNeeded() - } + // Preload Launch Services cache + Task { await LSRegisterCache().warmup() } } private static func installCrashHandlers() { @@ -66,13 +63,13 @@ struct MacOSCleanerApp: App { } .commands { CommandGroup(replacing: .appInfo) { - Button("About MacOS Cleaner") { + Button("about_title".localized) { openWindow(id: "about") } } } - Window("About MacOS Cleaner", id: "about") { + Window("about_title".localized, id: "about") { AboutView() } .windowResizability(.contentSize) diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index 3b15097..bf01aae 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -21,11 +21,11 @@ struct RootView: View { } detail: { if let selectedItem { contentView(for: selectedItem) - .frame(minWidth: 900, minHeight: 600) + .frame(minWidth: 800, minHeight: 600) } else { Text("sidebar_select_item".localized) .foregroundColor(.secondary) - .frame(minWidth: 900, minHeight: 600) + .frame(minWidth: 800, minHeight: 600) } } diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift new file mode 100644 index 0000000..0cc7e4a --- /dev/null +++ b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift @@ -0,0 +1,85 @@ +import Foundation + +extension CleanupCategory { + + public var localizedTitle: String { + "category.\(rawValue)".localized + } + + public static func fromFixturePathType(_ pathType: CleanupPathType) -> [CleanupCategory] { + switch pathType { + case .caches: + return [.appCaches, .browserCaches, .ideCaches, .messagingMedia, .languageCaches, .systemCaches] + case .applicationSupport: + return [.ideCaches, .orphanedRemnants, .orphanedFiles] + case .containers: + return [.appContainers, .orphanedRemnants] + case .groupContainers: + return [.appContainers, .orphanedRemnants] + case .preferences: + return [.orphanedRemnants, .savedAppState] + case .logs: + return [.userLogs, .crashReporter] + case .savedState: + return [.savedAppState] + case .httpStorages: + return [.orphanedFiles] + case .webkit: + return [.orphanedFiles] + case .applicationScripts: + return [.orphanedRemnants] + case .launchAgents: + return [.launchAgents] + case .launchDaemons: + return [.launchDaemons] + case .privilegedHelperTools: + return [.privilegedHelpers] + case .pkgReceipts: + return [.pkgReceipts] + case .internetPlugins: + return [.internetPlugins] + case .cookies: + return [.orphanedFiles] + case .diagnosticReports: + return [.crashReporter] + case .cloudDocs: + return [.cloudDocs] + case .sharedFileLists: + return [.sharedFileLists] + case .developerArtifacts: + return [] + } + } + + public static func fromCleanupJsonScannerId(_ scannerId: String) -> CleanupCategory? { + switch scannerId { + case "browser_data_scanner": return .browserCaches + case "logs_scanner": return .userLogs + case "language_toolchain_scanner": return .languageCaches + case "cache_scanner": return .appCaches + case "xcode_derived_data_scanner": return .xcode + case "simulator_scanner": return .iosSimulators + case "quicklook_cache_scanner": return .systemCaches + case "photo_library_cache_scanner": return .photosCache + case "voice_memos_scanner": return .voiceMemos + case "garageband_logic_scanner": return .garageBandLogic + case "imovie_final_cut_scanner": return .iMovieFinalCut + case "garmin_fitbit_scanner": return .garminFitbit + case "old_backups_scanner": return .oldBackups + case "mail_attachments_scanner": return .mailDownloads + case "dns_cache_scanner": return .dnsFlush + case "font_cache_scanner": return .fontCache + case "sleep_image_scanner": return .sleepImage + case "duplicate_files_scanner": return .duplicateFiles + case "unused_apps_scanner": return .unusedApps + case "docker_scanner": return .docker + case "downloads_scanner": return .largeFiles + case "trash_scanner": return .scatteredJunk + case "time_machine_local_snapshots_scanner": return .timeMachineSnapshots + case "itunes_backup_scanner": return .iosBackups + case "spotlight_index_scanner": return .systemCaches + case "swap_files_scanner": return .systemCaches + default: return nil + } + } +} diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index b7d79c1..20ffb32 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -207,49 +207,55 @@ public final class CleanupCoordinator: @unchecked Sendable { } private func parseSkippedFromLog(_ log: String) -> SkippedCleanupItem? { - let patterns: [(label: String, keywords: [String])] = [ - ("App containers", ["App containers"]), - ("Orphaned remnants", ["Orphaned remnants"]), - ("Orphaned files", ["Orphaned files"]), - ("Large files", ["Large files"]), - ("Dynamic cache discovery", ["Dynamic cache discovery"]), - ("App caches", ["App caches"]), - ("Package managers", ["Package managers"]), - ("Gradle + Maven", ["Gradle"]), - ("Flutter / Dart", ["Flutter"]), - ("Xcode", ["Xcode"]), - ("iOS Simulators", ["iOS Simulators"]), - ("Android caches", ["Android caches"]), - ("Android SDK", ["Android SDK"]), - ("IDE / Electron caches", ["IDE"]), - ("Browser caches", ["Browser caches"]), - ("Messaging / media", ["Messaging"]), - ("Docker", ["Docker"]), - ("Language caches", ["Language caches"]), - ("User logs", ["User logs"]), - ("System caches", ["System caches"]), - ("Dotfile caches", ["Dotfile caches"]), - ("Scattered junk", ["Scattered junk"]), - ("Time Machine Snapshots", ["Time Machine"]), - ("iOS Backups", ["iOS Backups"]), - ("Mail Downloads", ["Mail Downloads"]), - ("Saved Application State", ["Saved Application State"]), - ("Crash Reporter", ["Crash Reporter"]), - ("AssetsV2", ["AssetsV2"]), - ("CloudKit Cache", ["CloudKit"]), - ("Swift Package Manager Cache", ["SwiftPM"]), - ("Carthage Cache", ["Carthage"]), - ("Steam Cache", ["Steam"]), - ("Microsoft Teams Cache", ["Teams"]), - ("Adobe Caches", ["Adobe"]), - ("Chrome Extra Caches", ["Chrome"]), + let patterns: [(category: CleanupCategory?, keywords: [String])] = [ + (.appContainers, ["App containers"]), + (.orphanedRemnants, ["Orphaned remnants"]), + (.orphanedFiles, ["Orphaned files"]), + (.largeFiles, ["Large files"]), + (.dynamicCacheDiscovery, ["Dynamic cache discovery"]), + (.appCaches, ["App caches"]), + (.packageManagers, ["Package managers"]), + (.gradleMaven, ["Gradle"]), + (.flutterDart, ["Flutter"]), + (.xcode, ["Xcode"]), + (.iosSimulators, ["iOS Simulators"]), + (.androidCaches, ["Android caches"]), + (.androidSDK, ["Android SDK"]), + (.ideCaches, ["IDE"]), + (.browserCaches, ["Browser caches"]), + (.messagingMedia, ["Messaging"]), + (.docker, ["Docker"]), + (.languageCaches, ["Language caches"]), + (.userLogs, ["User logs"]), + (.systemCaches, ["System caches"]), + (.dotfileCaches, ["Dotfile caches"]), + (.scatteredJunk, ["Scattered junk"]), + (.timeMachineSnapshots, ["Time Machine"]), + (.iosBackups, ["iOS Backups"]), + (.mailDownloads, ["Mail Downloads"]), + (.savedAppState, ["Saved Application State"]), + (.crashReporter, ["Crash Reporter"]), + (.assetsV2, ["AssetsV2"]), + (.cloudKitCache, ["CloudKit"]), + (.swiftPMCache, ["SwiftPM"]), + (.carthageCache, ["Carthage"]), + (.steamCache, ["Steam"]), + (.teamsCache, ["Teams"]), + (.adobeCaches, ["Adobe"]), + (.chromeExtraCaches, ["Chrome"]), ] guard let matched = patterns.first(where: { $0.keywords.contains(where: { log.contains($0) }) }) else { return nil } - // Extract reason — everything between "—" and ", skipped" + let label: String + if let category = matched.category { + label = category.localizedTitle + } else { + label = matched.keywords[0] + } + let reason: String if let range = log.range(of: "— ") { let afterDash = log[range.upperBound...] @@ -262,7 +268,7 @@ public final class CleanupCoordinator: @unchecked Sendable { reason = "unknown" } - return SkippedCleanupItem(label: matched.label, reason: reason) + return SkippedCleanupItem(label: label, reason: reason) } @MainActor diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index a2e74b9..a973694 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -1,5 +1,6 @@ import Foundation import OSLog +import CoreServices private extension Logger { static let engine = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "CleanupEngine") @@ -100,6 +101,28 @@ public enum CleanupCategory: String, CaseIterable, Sendable { case adobeCaches = "adobe_caches" case chromeExtraCaches = "chrome_extra_caches" case ideOldVersions = "ide_old_versions" + + // Phase 3: New categories from fixtures + case launchAgents = "launch_agents" + case launchDaemons = "launch_daemons" + case privilegedHelpers = "privileged_helpers" + case pkgReceipts = "pkg_receipts" + case internetPlugins = "internet_plugins" + case sharedFileLists = "shared_file_lists" + case cloudDocs = "cloud_docs" + + // Phase 3: New categories from cleanup.json + case photosCache = "photos_cache" + case voiceMemos = "voice_memos" + case garageBandLogic = "garage_band_logic" + case iMovieFinalCut = "imovie_final_cut" + case garminFitbit = "garmin_fitbit" + case oldBackups = "old_backups" + case dnsFlush = "dns_flush" + case fontCache = "font_cache" + case sleepImage = "sleep_image" + case duplicateFiles = "duplicate_files" + case unusedApps = "unused_apps" } // MARK: - CleanupEngine Actor @@ -275,6 +298,24 @@ public actor CleanupEngine { case .teamsCache: return try await cleanTeamsCache(dryRun: dryRun, progress: progress) case .adobeCaches: return try await cleanAdobeCaches(dryRun: dryRun, progress: progress) case .chromeExtraCaches: return try await cleanChromeExtraCaches(dryRun: dryRun, progress: progress) + case .launchAgents: return try await cleanLaunchAgents(dryRun: dryRun, progress: progress) + case .launchDaemons: return try await cleanLaunchDaemons(dryRun: dryRun, progress: progress) + case .privilegedHelpers: return try await cleanPrivilegedHelpers(dryRun: dryRun, progress: progress) + case .pkgReceipts: return try await cleanPkgReceipts(dryRun: dryRun, progress: progress) + case .internetPlugins: return try await cleanInternetPlugins(dryRun: dryRun, progress: progress) + case .sharedFileLists: return try await cleanSharedFileLists(dryRun: dryRun, progress: progress) + case .cloudDocs: return try await cleanCloudDocs(dryRun: dryRun, progress: progress) + case .photosCache: return try await cleanPhotosCache(dryRun: dryRun, progress: progress) + case .voiceMemos: return try await cleanVoiceMemos(dryRun: dryRun, progress: progress) + case .garageBandLogic: return try await cleanGarageBandLogic(dryRun: dryRun, progress: progress) + case .iMovieFinalCut: return try await cleanIMovieFinalCut(dryRun: dryRun, progress: progress) + case .garminFitbit: return try await cleanGarminFitbit(dryRun: dryRun, progress: progress) + case .oldBackups: return try await cleanOldBackups(dryRun: dryRun, progress: progress) + case .dnsFlush: return try await cleanDNSFlush(dryRun: dryRun, progress: progress) + case .fontCache: return try await cleanFontCache(dryRun: dryRun, progress: progress) + 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) } } @@ -288,6 +329,8 @@ public actor CleanupEngine { return .seconds(600) case .orphanedRemnants, .appContainers, .dynamicCacheDiscovery, .largeFiles, .orphanedFiles: return .seconds(300) + case .launchDaemons, .privilegedHelpers, .sleepImage, .duplicateFiles: + return .seconds(120) default: return .seconds(60) } @@ -361,44 +404,7 @@ public actor CleanupEngine { // MARK: - Category Titles private static func titleForCategory(_ category: CleanupCategory) -> String { - switch category { - case .appCaches: return "User app caches" - case .packageManagers: return "Package managers" - case .gradleMaven: return "Gradle + Maven" - case .flutterDart: return "Flutter / Dart" - case .xcode: return "Xcode" - case .iosSimulators: return "iOS Simulators" - case .androidCaches: return "Android caches" - case .androidSDK: return "Android SDK" - case .ideCaches: return "IDE / Electron caches" - case .browserCaches: return "Browser caches" - case .messagingMedia: return "Messaging / media" - case .docker: return "Docker" - case .languageCaches: return "Language caches" - case .userLogs: return "User logs" - case .systemCaches: return "System caches" - case .appContainers: return "App containers" - case .dotfileCaches: return "Dotfile caches" - case .scatteredJunk: return "Scattered junk" - case .orphanedRemnants: return "Orphaned remnants" - case .orphanedFiles: return "Orphaned files" - case .largeFiles: return "Large files" - case .dynamicCacheDiscovery: return "Dynamic cache discovery" - case .timeMachineSnapshots: return "Time Machine Snapshots" - case .iosBackups: return "iOS Backups" - case .mailDownloads: return "Mail Downloads" - case .savedAppState: return "Saved Application State" - case .crashReporter: return "Crash Reporter" - case .assetsV2: return "AssetsV2 / iWork Templates" - case .cloudKitCache: return "CloudKit Cache" - case .swiftPMCache: return "Swift Package Manager Cache" - case .carthageCache: return "Carthage Cache" - case .steamCache: return "Steam Cache" - case .teamsCache: return "Microsoft Teams Cache" - case .adobeCaches: return "Adobe Caches" - case .chromeExtraCaches: return "Chrome Extra Caches" - case .ideOldVersions: return "Old IDE Versions" - } + category.localizedTitle } } @@ -416,13 +422,28 @@ public struct CleanupOptions: Sendable, Equatable { public var cleanProjects: Bool = true /// Xcode Archives older than this many days will be cleaned. public var xcodeArchivesOlderThanDays: Int = 90 - - public init(cleanDSStore: Bool = false, cleanMaven: Bool = true, cleanModCache: Bool = true, cleanProjects: Bool = true, xcodeArchivesOlderThanDays: Int = 90) { + /// When true, cleans CloudDocs (iCloud document cache). + public var cleanCloudDocs: Bool = false + /// When true, cleans Voice Memos recordings. + public var cleanVoiceMemos: Bool = false + /// When true, cleans GarageBand / Logic Pro projects. + public var cleanGarageBandLogic: Bool = false + /// When true, cleans iMovie / Final Cut render files. + public var cleanIMovieFinalCut: Bool = false + /// When true, removes sleep image (disables hibernation). + public var cleanSleepImage: 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) { self.cleanDSStore = cleanDSStore self.cleanMaven = cleanMaven self.cleanModCache = cleanModCache self.cleanProjects = cleanProjects self.xcodeArchivesOlderThanDays = xcodeArchivesOlderThanDays + self.cleanCloudDocs = cleanCloudDocs + self.cleanVoiceMemos = cleanVoiceMemos + self.cleanGarageBandLogic = cleanGarageBandLogic + self.cleanIMovieFinalCut = cleanIMovieFinalCut + self.cleanSleepImage = cleanSleepImage } /// Returns ALL categories for scanning (like the shell script always does). @@ -468,11 +489,39 @@ public struct CleanupOptions: Sendable, Equatable { .adobeCaches, .chromeExtraCaches, .ideOldVersions, + .launchAgents, + .launchDaemons, + .privilegedHelpers, + .pkgReceipts, + .internetPlugins, + .sharedFileLists, + .photosCache, + .garminFitbit, + .oldBackups, + .dnsFlush, + .fontCache, + .duplicateFiles, + .unusedApps, ] if cleanDSStore { categories.append(.scatteredJunk) } + if cleanCloudDocs { + categories.append(.cloudDocs) + } + if cleanVoiceMemos { + categories.append(.voiceMemos) + } + if cleanGarageBandLogic { + categories.append(.garageBandLogic) + } + if cleanIMovieFinalCut { + categories.append(.iMovieFinalCut) + } + if cleanSleepImage { + categories.append(.sleepImage) + } return categories } @@ -487,9 +536,9 @@ public enum CleanupEngineError: Error, LocalizedError, Equatable { public var errorDescription: String? { switch self { - case .timeout: return "Operation timed out" - case .safetyViolation(let path): return "Safety violation: \(path)" - case .commandFailed(let msg): return "Command failed: \(msg)" + case .timeout: return "error_timeout".localized + case .safetyViolation(let path): return String(format: "error_safety_violation_format".localized, path) + case .commandFailed(let msg): return String(format: "error_command_failed_format".localized, msg) } } @@ -508,10 +557,10 @@ public enum CleanupEngineError: Error, LocalizedError, Equatable { extension CleanupEngine { static func formatBytes(_ bytes: Int64) -> String { - if bytes < 1024 { return "\(bytes) B" } - if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } - if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) } - return String(format: "%.2f GB", Double(bytes) / (1024 * 1024 * 1024)) + if bytes < 1024 { return String(format: "format_bytes_b".localized, bytes) } + if bytes < 1024 * 1024 { return String(format: "format_bytes_kb".localized, Double(bytes) / 1024) } + if bytes < 1024 * 1024 * 1024 { return String(format: "format_bytes_mb".localized, Double(bytes) / (1024 * 1024)) } + return String(format: "format_bytes_gb".localized, Double(bytes) / (1024 * 1024 * 1024)) } static func shortPath(_ path: String) -> String { @@ -1181,13 +1230,6 @@ extension CleanupEngine { "\(home)/Library/Application Support/ai.opencode.desktop/Crashpad", "\(home)/Library/Application Support/ai.opencode.desktop/Session Storage", "\(home)/Library/Application Support/ai.opencode.desktop/Service Worker", - // OrbStack - "\(home)/Library/Application Support/OrbStack/Cache", - "\(home)/Library/Application Support/OrbStack/CachedData", - "\(home)/Library/Application Support/OrbStack/Code Cache", - "\(home)/Library/Application Support/OrbStack/GPUCache", - "\(home)/Library/Application Support/OrbStack/Service Worker", - "\(home)/Library/Application Support/OrbStack/Session Storage", // Nova (Panic) "\(home)/Library/Application Support/Nova/Caches", "\(home)/Library/Caches/com.panic.Nova", @@ -2188,6 +2230,10 @@ extension CleanupEngine { "\(home)/Library/Containers", "\(home)/Library/Group Containers", "\(home)/Library/Cookies", + "\(home)/Library/HTTPStorages", + "\(home)/Library/WebKit", + "\(home)/Library/Application Scripts", + "\(home)/Library/Internet Plug-Ins", "/Users/Shared" ] @@ -2359,6 +2405,28 @@ extension CleanupEngine { } } + // Scan ~/Library/Cookies for orphaned entries (Phase 4 enhancement) + let cookiesDir = "\(home)/Library/Cookies" + if fm.fileExists(atPath: cookiesDir) { + let entries = (try? fm.contentsOfDirectory(atPath: cookiesDir)) ?? [] + for entry in entries { + if entry.hasPrefix("com.apple.") { continue } + if !isEntryInstalled(entry, installedApps: installedApps) { + let entryPath = "\(cookiesDir)/\(entry)" + let entrySize = (try? fm.attributesOfItem(atPath: entryPath)[.size] as? Int64) ?? 0 + if entrySize > 1024 * 1024 { + if dryRun { + freed += entrySize + progress?(.log(" Orphaned Cookie: \(entry) — \(Self.formatBytes(entrySize))")) + } else { + try? fm.removeItem(atPath: entryPath) + freed += entrySize + } + } + } + } + } + // Scan ~/Library/WebKit for orphaned entries let webkitDir = "\(home)/Library/WebKit" if fm.fileExists(atPath: webkitDir) { @@ -2692,7 +2760,7 @@ extension CleanupEngine { let paths = [ "\(home)/Library/Mail Downloads", - "\(home)/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" + "\(home)/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", ] for path in paths { @@ -2701,6 +2769,18 @@ extension CleanupEngine { if dryRun { emitFileItem(item, category: "Mail Downloads", parentName: nil, progress: progress) } } + // Enhanced: Mail Attachments from cleanup.json + let mailDir = "\(home)/Library/Mail" + if fm.fileExists(atPath: mailDir) { + let mailAccounts = (try? fm.contentsOfDirectory(atPath: mailDir)) ?? [] + for account in mailAccounts { + let attachmentsPath = "\(mailDir)/\(account)/Attachments" + let (f, item) = try await cleanContents(of: attachmentsPath, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Mail Downloads", parentName: "Mail Attachments", progress: progress) } + } + } + let mb = Int(freed / (1024 * 1024)) progress?(.result(label: "Mail Downloads", freedMB: mb)) return [CleanupEngineResult(label: "Mail Downloads", freedMB: mb)] @@ -2933,7 +3013,9 @@ extension CleanupEngine { var freed: Int64 = 0 let chromeBase = "\(home)/Library/Application Support/Google/Chrome/Default" + let chromeBaseRoot = "\(home)/Library/Application Support/Google/Chrome" let subdirs = ["Cache", "Code Cache", "GPUCache", "Service Worker", "Session Storage"] + let rootSubdirs = ["GrShaderCache", "ShaderCache"] // Check if Chrome is running and warn let isChromeRunning = await isAppRunning(bundleIdentifier: "com.google.Chrome") @@ -2947,6 +3029,13 @@ extension CleanupEngine { freed += f if dryRun { emitFileItem(item, category: "Chrome Extra Caches", parentName: nil, progress: progress) } } + // Also clean Chrome root-level GPU shader caches + for sub in rootSubdirs { + let path = "\(chromeBaseRoot)/\(sub)" + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Chrome Extra Caches", parentName: "Root shader cache", progress: progress) } + } let mb = Int(freed / (1024 * 1024)) progress?(.result(label: "Chrome Extra Caches", freedMB: mb)) @@ -2957,6 +3046,352 @@ extension CleanupEngine { let result = try? await commandRunner.run(command: "/bin/bash", arguments: ["-c", "pgrep -x \(bundleIdentifier) >/dev/null 2>&1"]) return result?.exitCode == 0 } + + // MARK: 36. Launch Agents (user) + + func cleanLaunchAgents(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning user LaunchAgents...")) + let (freed, item) = try await cleanContents(of: "\(home)/Library/LaunchAgents", dryRun: dryRun, progress: progress) + if dryRun { emitFileItem(item, category: "Launch Agents", parentName: nil, progress: progress) } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Launch Agents", freedMB: mb)) + return [CleanupEngineResult(label: "Launch Agents", freedMB: mb)] + } + + // MARK: 37. Launch Daemons (system) + + func cleanLaunchDaemons(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log("Scanning Launch Daemons (system)...")) + progress?(.log(" Requires Full Disk Access — skipped in scan")) + if !dryRun { + let (freed, _) = try await cleanContents(of: "/Library/LaunchDaemons", dryRun: false, progress: progress) + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Launch Daemons", freedMB: mb)) + return [CleanupEngineResult(label: "Launch Daemons", freedMB: mb)] + } + return [CleanupEngineResult(label: "Launch Daemons", freedMB: 0)] + } + + // MARK: 38. Privileged Helper Tools + + func cleanPrivilegedHelpers(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log("Scanning Privileged Helper Tools...")) + progress?(.log(" Requires Full Disk Access — skipped in scan")) + if !dryRun { + let (freed, _) = try await cleanContents(of: "/Library/PrivilegedHelperTools", dryRun: false, progress: progress) + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Privileged Helper Tools", freedMB: mb)) + return [CleanupEngineResult(label: "Privileged Helper Tools", freedMB: mb)] + } + return [CleanupEngineResult(label: "Privileged Helper Tools", freedMB: 0)] + } + + // MARK: 39. Package Receipts + + func cleanPkgReceipts(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning package receipts...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Library/Receipts", + "/Library/Receipts", + ] + for path in paths { + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Package Receipts", parentName: nil, progress: progress) } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Package Receipts", freedMB: mb)) + return [CleanupEngineResult(label: "Package Receipts", freedMB: mb)] + } + + // MARK: 40. Internet Plugins + + func cleanInternetPlugins(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning internet plugins...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Library/Internet Plug-Ins", + "/Library/Internet Plug-Ins", + ] + for path in paths { + guard fm.fileExists(atPath: path) else { continue } + let (f, item) = try await removeDirectory(path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Internet Plugins", parentName: nil, progress: progress) } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Internet Plugins", freedMB: mb)) + return [CleanupEngineResult(label: "Internet Plugins", freedMB: mb)] + } + + // MARK: 41. Shared File Lists + + func cleanSharedFileLists(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning shared file lists...")) + let (freed, item) = try await cleanContents(of: "\(home)/Library/Application Support/com.apple.sharedfilelist", dryRun: dryRun, progress: progress) + if dryRun { emitFileItem(item, category: "Shared File Lists", parentName: nil, progress: progress) } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Shared File Lists", freedMB: mb)) + return [CleanupEngineResult(label: "Shared File Lists", freedMB: mb)] + } + + // MARK: 42. Cloud Docs + + func cleanCloudDocs(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning CloudDocs...")) + let (freed, item) = try await cleanContents(of: "\(home)/Library/Application Support/CloudDocs", dryRun: dryRun, progress: progress) + if dryRun { emitFileItem(item, category: "Cloud Docs", parentName: nil, progress: progress) } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Cloud Docs", freedMB: mb)) + return [CleanupEngineResult(label: "Cloud Docs", freedMB: mb)] + } + + // MARK: 43. Photos Cache + + func cleanPhotosCache(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning Photos cache...")) + let (freed, item) = try await cleanContents(of: "\(home)/Library/Containers/com.apple.Photos/Data/Library/Caches", dryRun: dryRun, progress: progress) + if dryRun { emitFileItem(item, category: "Photos Cache", parentName: nil, progress: progress) } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Photos Cache", freedMB: mb)) + return [CleanupEngineResult(label: "Photos Cache", freedMB: mb)] + } + + // MARK: 44. Voice Memos + + func cleanVoiceMemos(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning Voice Memos...")) + let (freed, item) = try await cleanContents(of: "\(home)/Library/Application Support/com.apple.VoiceMemos/Recordings", dryRun: dryRun, progress: progress) + if dryRun { emitFileItem(item, category: "Voice Memos", parentName: nil, progress: progress) } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Voice Memos", freedMB: mb)) + return [CleanupEngineResult(label: "Voice Memos", freedMB: mb)] + } + + // MARK: 45. GarageBand / Logic Pro + + func cleanGarageBandLogic(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning GarageBand / Logic Pro...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Music/GarageBand", + "\(home)/Music/Logic", + "\(home)/Library/Containers/com.apple.garageband10/Data/Library/Caches", + ] + for path in paths { + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "GarageBand / Logic Pro", parentName: nil, progress: progress) } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "GarageBand / Logic Pro", freedMB: mb)) + return [CleanupEngineResult(label: "GarageBand / Logic Pro", freedMB: mb)] + } + + // MARK: 46. iMovie / Final Cut + + func cleanIMovieFinalCut(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning iMovie / Final Cut...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Movies/iMovie Library.imovielibrary", + "\(home)/Movies/Final Cut Pro Libraries", + "\(home)/Library/Caches/com.apple.iMovieApp", + ] + for path in paths { + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "iMovie / Final Cut", parentName: nil, progress: progress) } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "iMovie / Final Cut", freedMB: mb)) + return [CleanupEngineResult(label: "iMovie / Final Cut", freedMB: mb)] + } + + // MARK: 47. Garmin / Fitbit + + func cleanGarminFitbit(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning Garmin / Fitbit caches...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Library/Caches/com.garmin.connectiq", + "\(home)/Library/Caches/com.fitbit.Fitbit-OS-Simulator", + ] + for path in paths { + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Garmin / Fitbit", parentName: nil, progress: progress) } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Garmin / Fitbit", freedMB: mb)) + return [CleanupEngineResult(label: "Garmin / Fitbit", freedMB: mb)] + } + + // MARK: 48. Old Backups + + func cleanOldBackups(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + let home = fm.homeDirectoryForCurrentUser.path + progress?(.log("Scanning old backups...")) + var freed: Int64 = 0 + let paths = [ + "\(home)/Backups", + ] + for path in paths { + let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Old Backups", parentName: nil, progress: progress) } + } + // Find *.backup files + let backupDirs = ["\(home)/Desktop", "\(home)/Documents", "\(home)/Downloads"] + for dir in backupDirs { + guard fm.fileExists(atPath: dir) else { continue } + let contents = (try? fm.contentsOfDirectory(atPath: dir)) ?? [] + for file in contents where file.hasSuffix(".backup") { + let filePath = "\(dir)/\(file)" + let (f, item) = try await removeFile(filePath, dryRun: dryRun, progress: progress) + freed += f + if dryRun { emitFileItem(item, category: "Old Backups", parentName: nil, progress: progress) } + } + } + let mb = Int(freed / (1024 * 1024)) + progress?(.result(label: "Old Backups", freedMB: mb)) + return [CleanupEngineResult(label: "Old Backups", freedMB: mb)] + } + + // MARK: 49. DNS Flush + + 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?(.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)")) + } + progress?(.result(label: "DNS Cache", freedMB: 0)) + return [CleanupEngineResult(label: "DNS Cache", freedMB: 0)] + } + + // MARK: 50. Font Cache + + func cleanFontCache(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log("Clearing font cache...")) + if dryRun { + progress?(.log(" Would run: sudo atsutil databases -remove")) + progress?(.log(" Requires restart to take effect")) + progress?(.result(label: "Font Cache", freedMB: 0)) + return [CleanupEngineResult(label: "Font Cache", freedMB: 0)] + } + let result = try? await commandRunner.run(command: "/bin/bash", arguments: ["-c", "sudo atsutil databases -remove"]) + if result?.exitCode == 0 { + progress?(.log(" Font cache cleared — restart required")) + } else { + progress?(.log(" Font cache clear failed (may need sudo without password)")) + } + progress?(.result(label: "Font Cache", freedMB: 0)) + return [CleanupEngineResult(label: "Font Cache", freedMB: 0)] + } + + // MARK: 51. Sleep Image + + func cleanSleepImage(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log("Checking sleep image...")) + let size = await getDirectorySize("/var/vm/sleepimage") + if size > 0 { + progress?(.log(" Sleep image: \(Self.formatBytes(size))")) + if dryRun { + progress?(.log(" Would disable hibernation and remove sleep image")) + emitFileItem(CleanupFileItem(path: "/var/vm/sleepimage", sizeBytes: size, modificationDate: nil, isDirectory: false), category: "Sleep Image", parentName: nil, progress: progress) + progress?(.result(label: "Sleep Image", freedMB: Int(size / (1024 * 1024)))) + return [CleanupEngineResult(label: "Sleep Image", freedMB: Int(size / (1024 * 1024)))] + } + let result = try? await commandRunner.run(command: "/bin/bash", arguments: ["-c", "sudo pmset hibernatemode 0; sudo rm /var/vm/sleepimage"]) + if result?.exitCode == 0 { + progress?(.log(" Sleep image removed, hibernation disabled")) + progress?(.result(label: "Sleep Image", freedMB: Int(size / (1024 * 1024)))) + return [CleanupEngineResult(label: "Sleep Image", freedMB: Int(size / (1024 * 1024)))] + } else { + progress?(.log(" Failed to remove sleep image")) + } + } else { + progress?(.log(" No sleep image found")) + } + progress?(.result(label: "Sleep Image", freedMB: 0)) + return [CleanupEngineResult(label: "Sleep Image", freedMB: 0)] + } + + // MARK: 52. Duplicate Files (scanning only — stub) + + func cleanDuplicateFiles(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log(" Duplicate detection requires sha256 — recommend dedicated tool")) + progress?(.log(" Skipping — not implemented")) + progress?(.result(label: "Duplicate Files", freedMB: 0)) + return [CleanupEngineResult(label: "Duplicate Files", freedMB: 0)] + } + + // MARK: 53. Unused Apps (scanning only) + + func cleanUnusedApps(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { + progress?(.log("Scanning for unused apps...")) + progress?(.log(" Checking apps not launched in 180 days...")) + + let appPaths = ["/Applications", "\(fm.homeDirectoryForCurrentUser.path)/Applications", "/Applications/Setapp"] + var unusedApps: [(String, String, Date?)] = [] + + let cutoffDate = Calendar.current.date(byAdding: .day, value: -180, to: Date())! + + for basePath in appPaths { + guard fm.fileExists(atPath: basePath) else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: basePath) else { continue } + for item in contents where item.hasSuffix(".app") { + let appPath = "\(basePath)/\(item)" + guard let bundle = Bundle(url: URL(fileURLWithPath: appPath)), + let bundleID = bundle.bundleIdentifier else { continue } + // Skip Apple apps + if bundleID.hasPrefix("com.apple.") { continue } + // Check last launch date via spotlight metadata + let mdItem = MDItemCreate(nil, appPath as CFString) + if let mdItem = mdItem { + if let lastUsed = MDItemCopyAttribute(mdItem, kMDItemLastUsedDate) as? Date { + if lastUsed < cutoffDate { + unusedApps.append((item.replacingOccurrences(of: ".app", with: ""), appPath, lastUsed)) + } + } + } + } + } + + if dryRun { + for (name, path, lastUsed) in unusedApps { + let dateStr = lastUsed.map { fmtDate($0) } ?? "unknown" + progress?(.log(" \(name) — last used: \(dateStr) [\(Self.shortPath(path))]")) + } + } + progress?(.log(" Found \(unusedApps.count) potentially unused apps")) + progress?(.log(" Unused apps are for review only — no automatic deletion")) + progress?(.result(label: "Unused Apps", freedMB: 0)) + return [CleanupEngineResult(label: "Unused Apps", freedMB: 0)] + } + + private func fmtDate(_ date: Date) -> String { + DateFormatter.makeLocalized(dateStyle: .medium).string(from: date) + } } diff --git a/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift b/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift new file mode 100644 index 0000000..63a8d45 --- /dev/null +++ b/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift @@ -0,0 +1,74 @@ +import Foundation + +public struct CleanupPath: Sendable, Equatable, Hashable { + public let path: String + public let category: CleanupCategory + public let requiresSudo: Bool + public let isDirectory: Bool + public let description: String? + + public init( + path: String, + category: CleanupCategory, + requiresSudo: Bool = false, + isDirectory: Bool = true, + description: String? = nil + ) { + self.path = path + self.category = category + self.requiresSudo = requiresSudo + self.isDirectory = isDirectory + self.description = description + } +} + +public protocol CleanupPathProvider: Sendable { + func paths(for category: CleanupCategory) -> [CleanupPath] + func allKnownApps() -> [String] + func commands(for category: CleanupCategory) -> [CleanupCommand] +} + +public struct CleanupCommand: Sendable, Equatable, Hashable { + public let command: String + public let description: String + public let requiresSudo: Bool + public let safe: Bool + public let requiresRestart: Bool + + public init( + command: String, + description: String, + requiresSudo: Bool = false, + safe: Bool = true, + requiresRestart: Bool = false + ) { + self.command = command + self.description = description + self.requiresSudo = requiresSudo + self.safe = safe + self.requiresRestart = requiresRestart + } +} + +public enum CleanupPathType: String, Sendable { + case caches + case applicationSupport = "application_support" + case containers + case groupContainers = "group_containers" + case preferences + case logs + case savedState = "saved_state" + case httpStorages = "http_storages" + case webkit + case applicationScripts = "application_scripts" + case launchAgents = "launch_agents" + case launchDaemons = "launch_daemons" + case privilegedHelperTools = "privileged_helper_tools" + case pkgReceipts = "pkg_receipts" + case internetPlugins = "internet_plugins" + case cookies + case diagnosticReports = "diagnostic_reports" + case cloudDocs = "cloud_docs" + case sharedFileLists = "shared_file_lists" + case developerArtifacts = "developer_artifacts" +} \ No newline at end of file diff --git a/MacOSCleaner/Domains/Cleanup/CleanupStateMachine.swift b/MacOSCleaner/Domains/Cleanup/CleanupStateMachine.swift index a5cbc8c..3f6c2db 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupStateMachine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupStateMachine.swift @@ -1,7 +1,7 @@ import Foundation import Observation -/// Состояния жизненного цикла процесса очистки. +/// States of the cleanup lifecycle. public enum CleanupState: Equatable, Sendable { case idle case scanning @@ -12,29 +12,29 @@ public enum CleanupState: Equatable, Sendable { case cancelled } -/// Машина состояний для управления процессом очистки. -/// Обеспечивает только валидные переходы между состояниями. +/// State machine for managing the cleanup process. +/// Ensures only valid state transitions. @Observable public final class CleanupStateMachine { public private(set) var state: CleanupState = .idle public init() {} - /// Ошибка при невалидном переходе. + /// Error for invalid state transitions. public enum StateError: Error, LocalizedError { case invalidTransition(from: CleanupState, to: CleanupState) public var errorDescription: String? { switch self { case .invalidTransition(let from, let to): - return "Invalid transition from \(from) to \(to)" + return String(format: "error_invalid_transition_format".localized, "\(from)", "\(to)") } } } - /// Выполняет переход в новое состояние. - /// - Parameter newState: Целевое состояние. - /// - Throws: `StateError.invalidTransition` если переход невозможен. + /// Transitions to a new state. + /// - Parameter newState: The target state. + /// - Throws: `StateError.invalidTransition` if the transition is invalid. public func transition(to newState: CleanupState) throws { guard isValidTransition(from: state, to: newState) else { throw StateError.invalidTransition(from: state, to: newState) @@ -42,13 +42,13 @@ public final class CleanupStateMachine { state = newState } - /// Сбрасывает машину состояний в начальное состояние. + /// Resets the state machine to the initial state. public func reset() { state = .idle } private func isValidTransition(from: CleanupState, to: CleanupState) -> Bool { - // Ошибка или отмена возможны из активных состояний + // Error or cancellation can occur from active states if to == .failed || to == .cancelled { return from == .scanning || from == .executing || from == .preview } diff --git a/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift b/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift new file mode 100644 index 0000000..6f93231 --- /dev/null +++ b/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift @@ -0,0 +1,493 @@ +import Foundation + +public enum EmbeddedCleanupPaths { + + // MARK: - App Caches + + public static let appCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/Google", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.google.SoftwareUpdate", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.google.GoogleUpdater", category: .appCaches), + CleanupPath(path: "~/Library/Application Support/Google/GoogleUpdater", category: .appCaches), + CleanupPath(path: "~/Library/Google/GoogleSoftwareUpdate", category: .appCaches), + CleanupPath(path: "~/Library/HTTPStorages/com.google.GoogleUpdater", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.carthage.CarthageKit", category: .appCaches), + CleanupPath(path: "~/Library/Caches/CocoaPods", category: .appCaches), + CleanupPath(path: "~/Library/Caches/pip", category: .appCaches), + CleanupPath(path: "~/Library/Caches/Homebrew", category: .appCaches), + CleanupPath(path: "~/Library/Caches/ms-playwright-go", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.spotify.client", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.Xcode", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.instruments", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.swift.swiftpm", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.plausiblelabs.crashreporter.data", category: .appCaches), + CleanupPath(path: "~/Library/Caches/JetBrains", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.SourceKitService", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.XcodePreviews", category: .appCaches), + CleanupPath(path: "~/Library/Caches/pnpm", category: .appCaches), + CleanupPath(path: "~/Library/Caches/yarn", category: .appCaches), + CleanupPath(path: "~/Library/Caches/npm", category: .appCaches), + CleanupPath(path: "~/Library/Caches/go-build", category: .appCaches), + CleanupPath(path: "~/Library/Caches/Adobe", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.discordapp.Discord", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.teams2", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.slack.Slack", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.tinyspeck.slackmacgap", category: .appCaches), + CleanupPath(path: "/Library/Caches", category: .appCaches, requiresSudo: true), + ] + + // MARK: - Browser Caches + + public static let browserCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/com.apple.Safari", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Apple/com.apple.Safari", category: .browserCaches), + CleanupPath(path: "~/Library/Safari/LocalStorage", category: .browserCaches), + CleanupPath(path: "~/Library/Safari/Databases", category: .browserCaches), + CleanupPath(path: "~/Library/WebKit/com.apple.Safari", category: .browserCaches), + CleanupPath(path: "~/Library/WebKit/WebsiteData", category: .browserCaches), + CleanupPath(path: "~/Library/Safari/Favicon Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.brave.Browser", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.operasoftware.Opera", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.Edge", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.firefox", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Firefox", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.google.Chrome", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.google.Chrome.beta", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.WebKit.Networking", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/BraveSoftware", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.vivaldi.Vivaldi", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/company.thebrowser.Browser", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/Code Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/GPUCache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/Service Worker", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/GrShaderCache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Firefox/Profiles/*/cache2", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Firefox/Profiles/*/startupCache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Firefox/Profiles/*/thumbnails", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Microsoft Edge/Default/Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Microsoft Edge/Default/Code Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Code Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Arc/User Data/Default/Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Arc/User Data/Default/Code Cache", category: .browserCaches), + ] + + // MARK: - Messaging / Media + + public static let messagingMedia: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/ru.keepcoder.Telegram", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.tinyspeck.slackmacgap", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.hnc.Discord", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.spotify.client", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/us.zoom.xos", category: .messagingMedia), + CleanupPath(path: "~/Library/Messages/Attachments", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.signal.Signal", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.tencent.xinWeChat", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/com.microsoft.teams2", category: .messagingMedia), + CleanupPath(path: "~/Library/Caches/net.whatsapp.WhatsApp", category: .messagingMedia), + ] + + // MARK: - IDE Caches + + public static let ideCaches: [CleanupPath] = [ + // Cursor + CleanupPath(path: "~/Library/Application Support/Cursor/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/CachedExtensionVSIXs", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/User/workspaceStorage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Cursor/Service Worker", category: .ideCaches), + // VS Code + CleanupPath(path: "~/Library/Application Support/Code/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/CachedExtensionVSIXs", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/User/workspaceStorage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/Service Worker", category: .ideCaches), + // Windsurf + CleanupPath(path: "~/Library/Application Support/Windsurf/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/CachedExtensionVSIXs", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/User/workspaceStorage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Windsurf/Service Worker", category: .ideCaches), + // Zed + CleanupPath(path: "~/Library/Application Support/dev.zed.Zed/cache", category: .ideCaches), + CleanupPath(path: "~/.config/zed/cache", category: .ideCaches), + // opencode Desktop + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/CachedExtensionVSIXs", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/User/workspaceStorage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ai.opencode.desktop/Service Worker", category: .ideCaches), + // Nova + CleanupPath(path: "~/Library/Application Support/Nova/Caches", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.panic.Nova", category: .ideCaches), + // Sublime Text + CleanupPath(path: "~/Library/Application Support/Sublime Text/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Sublime Text/Index", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Sublime Text/Package Control.cache", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.sublimetext.4", category: .ideCaches), + // JetBrains + CleanupPath(path: "~/Library/Caches/JetBrains", category: .ideCaches), + CleanupPath(path: "~/Library/Logs/JetBrains", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/JetBrains/Toolbox/apps", category: .ideCaches), + // GitHub Desktop + CleanupPath(path: "~/Library/Application Support/GitHub Desktop/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/GitHub Desktop/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/GitHub Desktop/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/GitHub Desktop/Session Storage", category: .ideCaches), + // Figma / Notion / Linear / Postman / Insomnia + CleanupPath(path: "~/Library/Application Support/Figma/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Figma/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Figma/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Figma/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Notion/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Notion/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Notion/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Notion/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Linear/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Linear/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Linear/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Linear/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Postman/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Postman/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Postman/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Postman/Session Storage", category: .ideCaches), + // Claude / ChatGPT + CleanupPath(path: "~/Library/Application Support/Claude/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Claude/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Claude/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Claude/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Claude/Service Worker", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Claude/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/Service Worker", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/ChatGPT/Crashpad", category: .ideCaches), + // Slack / Discord + CleanupPath(path: "~/Library/Application Support/Slack/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Slack/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Slack/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Slack/Service Worker", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Slack/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/discord/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/discord/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/discord/Code Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/discord/Session Storage", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/discord/Crashpad", category: .ideCaches), + // GitHub Desktop + CleanupPath(path: "~/Library/Caches/com.github.GitHubClient", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.github.GitHubClient.ShipIt", category: .ideCaches), + // DBeaver + CleanupPath(path: "~/Library/Caches/org.jkiss.dbeaver.core.product", category: .ideCaches), + // TablePlus + CleanupPath(path: "~/Library/Caches/com.tinyapp.TablePlus", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.tableplus.TablePlus", category: .ideCaches), + // Vivaldi + CleanupPath(path: "~/Library/Caches/com.vivaldi.Vivaldi", category: .ideCaches), + ] + + // MARK: - Language Caches + + public static let languageCaches: [CleanupPath] = [ + CleanupPath(path: "~/.cargo/registry/cache", category: .languageCaches), + CleanupPath(path: "~/.cargo/registry/src", category: .languageCaches), + CleanupPath(path: "~/.cargo/.package-cache", category: .languageCaches), + CleanupPath(path: "~/.cargo/git", category: .languageCaches), + CleanupPath(path: "~/.bun/install/cache", category: .languageCaches), + CleanupPath(path: "~/.deno/cache", category: .languageCaches), + CleanupPath(path: "~/Library/Caches/deno", category: .languageCaches), + CleanupPath(path: "~/.volta/cache", category: .languageCaches), + CleanupPath(path: "~/.nvm/.cache", category: .languageCaches), + CleanupPath(path: "~/.cache/node-gyp", category: .languageCaches), + CleanupPath(path: "~/.node-gyp", category: .languageCaches), + CleanupPath(path: "~/.cache/Cypress", category: .languageCaches), + CleanupPath(path: "~/Library/Caches/Cypress", category: .languageCaches), + CleanupPath(path: "~/.cache/ms-playwright", category: .languageCaches), + CleanupPath(path: "~/.cache/ms-playwright-go", category: .languageCaches), + CleanupPath(path: "~/Library/Caches/ms-playwright", category: .languageCaches), + CleanupPath(path: "~/.cache/puppeteer", category: .languageCaches), + CleanupPath(path: "~/.composer/cache", category: .languageCaches), + CleanupPath(path: "~/Library/Caches/pypoetry", category: .languageCaches), + CleanupPath(path: "~/Library/Caches/uv", category: .languageCaches), + CleanupPath(path: "~/.cache/pip", category: .languageCaches), + CleanupPath(path: "~/.cache/pypoetry", category: .languageCaches), + CleanupPath(path: "~/.cache/uv", category: .languageCaches), + CleanupPath(path: "~/.cache/hatch", category: .languageCaches), + CleanupPath(path: "~/.rye/cache", category: .languageCaches), + CleanupPath(path: "~/.cache/pipx", category: .languageCaches), + CleanupPath(path: "~/.sbt", category: .languageCaches), + CleanupPath(path: "~/.ivy2/cache", category: .languageCaches), + CleanupPath(path: "~/.coursier/cache", category: .languageCaches), + CleanupPath(path: "~/.ammonite/cache", category: .languageCaches), + CleanupPath(path: "~/.cache/metals", category: .languageCaches), + CleanupPath(path: "~/.julia/compiled", category: .languageCaches), + CleanupPath(path: "~/.julia/logs", category: .languageCaches), + CleanupPath(path: "~/.hex/packages", category: .languageCaches), + CleanupPath(path: "~/.cabal/packages", category: .languageCaches), + CleanupPath(path: "~/.cabal/logs", category: .languageCaches), + CleanupPath(path: "~/.cache/org.swift.swiftpm", category: .languageCaches), + CleanupPath(path: "~/.swiftpm/cache", category: .languageCaches), + CleanupPath(path: "~/.swiftpm/repositories", category: .languageCaches), + CleanupPath(path: "~/.m2/repository", category: .languageCaches), + CleanupPath(path: "~/.pnpm-store", category: .languageCaches), + CleanupPath(path: "~/.yarn", category: .languageCaches), + CleanupPath(path: "~/.cache/yarn", category: .languageCaches), + CleanupPath(path: "~/.cache/poetry", category: .languageCaches), + CleanupPath(path: "~/.cache/bazel", category: .languageCaches), + CleanupPath(path: "~/.cache/bazelisk", category: .languageCaches), + CleanupPath(path: "~/.gem", category: .languageCaches), + CleanupPath(path: "~/.cocoapods", category: .languageCaches), + CleanupPath(path: "~/.pub-cache", category: .languageCaches), + CleanupPath(path: "~/.dartServer", category: .languageCaches), + ] + + // MARK: - System Caches + + public static let systemCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/com.apple.QuickLook.thumbnailcache", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.fontd", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.iconservices", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.metadata.SpotlightIndex", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Siri", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Assistant", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.parsecd", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.helpd", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/CloudKit", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.TimeMachine", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.diagnosticd", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Spotlight", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.quicklook.ThumbnailsAgent", category: .systemCaches), + CleanupPath(path: "~/Library/Caches/com.apple.quicklook.ThumbnailsAgent/Thumbnails", category: .systemCaches), + ] + + // MARK: - Dotfile Caches + + public static let dotfileCaches: [CleanupPath] = [ + CleanupPath(path: "~/.config/opencode/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/claude-cli/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/gemini/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/aider/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/continue/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/cody/cache", category: .dotfileCaches), + CleanupPath(path: "~/.local/share/ollama/models", category: .dotfileCaches), + CleanupPath(path: "~/.npm/_logs", category: .dotfileCaches), + CleanupPath(path: "~/.terraform.d/cache", category: .dotfileCaches), + CleanupPath(path: "~/.cache/helm/repository", category: .dotfileCaches), + CleanupPath(path: "~/.cache/bazel", category: .dotfileCaches), + CleanupPath(path: "~/.ccache", category: .dotfileCaches), + CleanupPath(path: "~/.vcpkg/cache", category: .dotfileCaches), + CleanupPath(path: "~/.local/share/Trash", category: .dotfileCaches), + ] + + // MARK: - User Logs + + public static let userLogs: [CleanupPath] = [ + CleanupPath(path: "~/Library/Logs", category: .userLogs), + CleanupPath(path: "~/Library/Logs/DiagnosticReports", category: .userLogs), + CleanupPath(path: "~/Library/Logs/CrashReporter", category: .userLogs), + CleanupPath(path: "~/Library/Application Support/CrashReporter", category: .userLogs), + CleanupPath(path: "~/Library/Logs/PanicReporter", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Retired", category: .userLogs), + CleanupPath(path: "~/Library/Logs/MobileSlideshows", category: .userLogs), + CleanupPath(path: "~/Library/Logs/stackshot", category: .userLogs), + CleanupPath(path: "/Library/Logs", category: .userLogs, requiresSudo: true), + CleanupPath(path: "/var/log", category: .userLogs, requiresSudo: true), + CleanupPath(path: "~/Library/Logs/Adobe", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Microsoft", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Docker Desktop", category: .userLogs), + CleanupPath(path: "~/Library/Logs/JetBrains", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Homebrew", category: .userLogs), + CleanupPath(path: "~/Library/Logs/CoreSimulator", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Unity", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Unity Hub", category: .userLogs), + CleanupPath(path: "~/Library/Logs/EpicGamesLauncher", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Steam", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Zoom", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Postman", category: .userLogs), + CleanupPath(path: "~/Library/Logs/TablePlus", category: .userLogs), + CleanupPath(path: "~/Library/Logs/GitKraken", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Insomnia", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Figma", category: .userLogs), + CleanupPath(path: "~/Library/Logs/BraveSoftware", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Google/Chrome", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Firefox", category: .userLogs), + CleanupPath(path: "~/Library/Logs/Microsoft Edge", category: .userLogs), + ] + + // MARK: - Saved App State + + public static let savedAppState: [CleanupPath] = [ + CleanupPath(path: "~/Library/Saved Application State", category: .savedAppState), + ] + + // MARK: - Crash Reporter + + public static let crashReporter: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/CrashReporter", category: .crashReporter), + CleanupPath(path: "~/Library/Logs/DiagnosticReports", category: .crashReporter), + CleanupPath(path: "/Library/Logs/DiagnosticReports", category: .crashReporter, requiresSudo: true), + ] + + // MARK: - Mail Downloads + + public static let mailDownloads: [CleanupPath] = [ + CleanupPath(path: "~/Library/Mail Downloads", category: .mailDownloads), + CleanupPath(path: "~/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", category: .mailDownloads), + CleanupPath(path: "~/Library/Mail/*/Attachments", category: .mailDownloads), + ] + + // MARK: - Launch Agents (NEW) + + public static let launchAgents: [CleanupPath] = [ + CleanupPath(path: "~/Library/LaunchAgents", category: .launchAgents), + ] + + // MARK: - Launch Daemons (NEW) + + public static let launchDaemons: [CleanupPath] = [ + CleanupPath(path: "/Library/LaunchDaemons", category: .launchDaemons, requiresSudo: true), + ] + + // MARK: - Privileged Helper Tools (NEW) + + public static let privilegedHelpers: [CleanupPath] = [ + CleanupPath(path: "/Library/PrivilegedHelperTools", category: .privilegedHelpers, requiresSudo: true), + ] + + // MARK: - Package Receipts (NEW) + + public static let pkgReceipts: [CleanupPath] = [ + CleanupPath(path: "/Library/Receipts", category: .pkgReceipts, requiresSudo: true), + CleanupPath(path: "~/Library/Receipts", category: .pkgReceipts), + ] + + // MARK: - Internet Plugins (NEW) + + public static let internetPlugins: [CleanupPath] = [ + CleanupPath(path: "~/Library/Internet Plug-Ins", category: .internetPlugins), + CleanupPath(path: "/Library/Internet Plug-Ins", category: .internetPlugins, requiresSudo: true), + ] + + // MARK: - Shared File Lists (NEW) + + public static let sharedFileLists: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/com.apple.sharedfilelist", category: .sharedFileLists), + ] + + // MARK: - Cloud Docs (NEW) + + public static let cloudDocs: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/CloudDocs", category: .cloudDocs), + ] + + // MARK: - Photos Cache (NEW) + + public static let photosCache: [CleanupPath] = [ + CleanupPath(path: "~/Library/Containers/com.apple.Photos/Data/Library/Caches", category: .photosCache), + ] + + // MARK: - Voice Memos (NEW) + + public static let voiceMemos: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/com.apple.VoiceMemos/Recordings", category: .voiceMemos), + ] + + // MARK: - GarageBand / Logic Pro (NEW) + + public static let garageBandLogic: [CleanupPath] = [ + CleanupPath(path: "~/Music/GarageBand", category: .garageBandLogic), + CleanupPath(path: "~/Music/Logic", category: .garageBandLogic), + CleanupPath(path: "~/Library/Containers/com.apple.garageband10/Data/Library/Caches", category: .garageBandLogic), + ] + + // MARK: - iMovie / Final Cut (NEW) + + public static let iMovieFinalCut: [CleanupPath] = [ + CleanupPath(path: "~/Movies/iMovie Library.imovielibrary", category: .iMovieFinalCut), + CleanupPath(path: "~/Movies/Final Cut Pro Libraries", category: .iMovieFinalCut), + CleanupPath(path: "~/Library/Caches/com.apple.iMovieApp", category: .iMovieFinalCut), + ] + + // MARK: - Garmin / Fitbit (NEW) + + public static let garminFitbit: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/com.garmin.connectiq", category: .garminFitbit), + CleanupPath(path: "~/Library/Caches/com.fitbit.Fitbit-OS-Simulator", category: .garminFitbit), + ] + + // MARK: - Old Backups (NEW) + + public static let oldBackups: [CleanupPath] = [ + CleanupPath(path: "~/Backups", category: .oldBackups), + CleanupPath(path: "~/Desktop/*.backup", category: .oldBackups), + CleanupPath(path: "~/Documents/*.backup", category: .oldBackups), + CleanupPath(path: "~/Downloads/*.backup", category: .oldBackups), + ] + + // MARK: - Commands + + public static let dnsFlushCommands: [CleanupCommand] = [ + CleanupCommand(command: "sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder", description: "Flush DNS cache", requiresSudo: true, safe: true), + ] + + public static let fontCacheCommands: [CleanupCommand] = [ + CleanupCommand(command: "sudo atsutil databases -remove", description: "Remove font databases", requiresSudo: true, safe: true, requiresRestart: true), + ] + + public static let sleepImageCommands: [CleanupCommand] = [ + CleanupCommand(command: "sudo pmset hibernatemode 0; sudo rm /var/vm/sleepimage", description: "Disable hibernation and remove sleepimage", requiresSudo: true, safe: false), + ] + + // MARK: - Accessor + + public static func paths(for category: CleanupCategory) -> [CleanupPath] { + switch category { + case .appCaches: return appCaches + case .browserCaches: return browserCaches + case .messagingMedia: return messagingMedia + case .ideCaches: return ideCaches + case .languageCaches: return languageCaches + case .systemCaches: return systemCaches + case .dotfileCaches: return dotfileCaches + case .userLogs: return userLogs + case .savedAppState: return savedAppState + case .crashReporter: return crashReporter + case .mailDownloads: return mailDownloads + case .launchAgents: return launchAgents + case .launchDaemons: return launchDaemons + case .privilegedHelpers: return privilegedHelpers + case .pkgReceipts: return pkgReceipts + case .internetPlugins: return internetPlugins + case .sharedFileLists: return sharedFileLists + case .cloudDocs: return cloudDocs + case .photosCache: return photosCache + case .voiceMemos: return voiceMemos + case .garageBandLogic: return garageBandLogic + case .iMovieFinalCut: return iMovieFinalCut + case .garminFitbit: return garminFitbit + case .oldBackups: return oldBackups + default: return [] + } + } + + public static func commands(for category: CleanupCategory) -> [CleanupCommand] { + switch category { + case .dnsFlush: return dnsFlushCommands + case .fontCache: return fontCacheCommands + case .sleepImage: return sleepImageCommands + default: return [] + } + } +} diff --git a/MacOSCleaner/Domains/Cleanup/TransactionJournal.swift b/MacOSCleaner/Domains/Cleanup/TransactionJournal.swift index 54f0243..0896180 100644 --- a/MacOSCleaner/Domains/Cleanup/TransactionJournal.swift +++ b/MacOSCleaner/Domains/Cleanup/TransactionJournal.swift @@ -1,8 +1,8 @@ import Foundation import os.log -/// Журнал транзакций очистки для обеспечения возможности отката и аудита. -/// Использует формат JSONL (JSON Lines) для обеспечения отказоустойчивости при записи. +/// Cleanup transaction journal for rollback and audit. +/// Uses JSONL (JSON Lines) format for crash-safe writes. public actor TransactionJournal { private let journalURL: URL private let archiveDirectoryURL: URL @@ -10,7 +10,7 @@ public actor TransactionJournal { private let decoder = JSONDecoder() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macoscleaner", category: "TransactionJournal") - /// Максимальный размер журнала перед ротацией (10MB) + /// Maximum journal size before rotation (10MB) private let maxJournalSize: Int64 = 10 * 1024 * 1024 public init(journalURL: URL? = nil) { @@ -27,8 +27,8 @@ public actor TransactionJournal { try? FileManager.default.createDirectory(at: archiveDirectoryURL, withIntermediateDirectories: true) } - /// Записывает новую транзакцию в журнал. - /// - Parameter transaction: Транзакция для записи. + /// Writes a new transaction to the journal. + /// - Parameter transaction: The transaction to write. public func log(transaction: CleanupTransaction) throws { let data = try encoder.encode(transaction) guard var line = String(data: data, encoding: .utf8) else { @@ -44,8 +44,8 @@ public actor TransactionJournal { } } - /// Выполняет запись строки в журнал с проверкой размера и ротацией. - /// Использует атомарную запись через замену всего файла (read-modify-write). + /// Writes a line to the journal with size check and rotation. + /// Uses atomic write via full file replacement (read-modify-write). private func performWrite(line: String) throws { if FileManager.default.fileExists(atPath: journalURL.path) { let attributes = try FileManager.default.attributesOfItem(atPath: journalURL.path) @@ -81,7 +81,7 @@ public actor TransactionJournal { } } - /// Ротирует журнал: архивирует текущий файл и создаёт новый. + /// Rotates the journal: archives the current file and creates a new one. private func rotateJournal() throws { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" @@ -94,7 +94,7 @@ public actor TransactionJournal { cleanupOldArchives() } - /// Удаляет архивы старше 30 дней, оставляя максимум 10 архивов. + /// Deletes archives older than 30 days, keeping a maximum of 10 archives. private func cleanupOldArchives() { guard let files = try? FileManager.default.contentsOfDirectory(at: archiveDirectoryURL, includingPropertiesForKeys: [.creationDateKey]) else { return @@ -116,7 +116,7 @@ public actor TransactionJournal { } } - /// Загружает все транзакции из журнала. + /// Loads all transactions from the journal. public func loadAll() throws -> [CleanupTransaction] { guard FileManager.default.fileExists(atPath: journalURL.path) else { return [] } let content = try String(contentsOf: journalURL, encoding: .utf8) @@ -128,7 +128,7 @@ public actor TransactionJournal { } } - /// Загружает все транзакции из журнала и архивов. + /// Loads all transactions from the journal and archives. public func loadAllWithArchives() throws -> [CleanupTransaction] { var allTransactions = try loadAll() @@ -147,14 +147,14 @@ public actor TransactionJournal { return allTransactions } - /// Очищает журнал транзакций. + /// Clears the transaction journal. public func clear() throws { if FileManager.default.fileExists(atPath: journalURL.path) { try FileManager.default.removeItem(at: journalURL) } } - /// Очищает журнал и все архивы. + /// Clears the journal and all archives. public func clearAll() throws { try clear() if FileManager.default.fileExists(atPath: archiveDirectoryURL.path) { @@ -163,7 +163,7 @@ public actor TransactionJournal { } } - /// Возвращает размер журнала в байтах. + /// Returns the journal size in bytes. public func journalSize() throws -> Int64 { guard FileManager.default.fileExists(atPath: journalURL.path) else { return 0 } let attributes = try FileManager.default.attributesOfItem(atPath: journalURL.path) diff --git a/MacOSCleaner/Domains/ProcessManagement/ProcessManager.swift b/MacOSCleaner/Domains/ProcessManagement/ProcessManager.swift index bf17fa3..2f01c4b 100644 --- a/MacOSCleaner/Domains/ProcessManagement/ProcessManager.swift +++ b/MacOSCleaner/Domains/ProcessManagement/ProcessManager.swift @@ -182,11 +182,11 @@ public enum ProcessManagerError: Error, LocalizedError { public var errorDescription: String? { switch self { case .psFailed(let stderr): - return "Failed to list processes: \(stderr)" + return String(format: "error_ps_failed_format".localized, stderr) case .operationBlocked(let name, let reason): - return "Cannot terminate \(name): \(reason)" + return String(format: "error_operation_blocked_format".localized, name, reason) case .killFailed(let name, let code, let stderr): - return "Failed to kill \(name) (exit \(code)): \(stderr)" + return String(format: "error_kill_failed_format".localized, name, code, stderr) } } } diff --git a/MacOSCleaner/Domains/ProcessManagement/ProcessSafetyPolicy.swift b/MacOSCleaner/Domains/ProcessManagement/ProcessSafetyPolicy.swift index ec6d631..24763d9 100644 --- a/MacOSCleaner/Domains/ProcessManagement/ProcessSafetyPolicy.swift +++ b/MacOSCleaner/Domains/ProcessManagement/ProcessSafetyPolicy.swift @@ -51,19 +51,19 @@ public struct ProcessSafetyPolicy: Sendable { let name = process.name if process.pid <= 1 { - return .blocked(reason: "PID \(process.pid) is a system-critical process") + return .blocked(reason: String(format: "process_block_pid_format".localized, process.pid)) } if userWhitelist.contains(name) { - return .blocked(reason: "\(name) is in your whitelist (protected)") + return .blocked(reason: String(format: "process_block_whitelist_name_format".localized, name)) } if let bundleID = process.bundleID, userWhitelist.contains(bundleID) { - return .blocked(reason: "\(bundleID) is in your whitelist (protected)") + return .blocked(reason: String(format: "process_block_whitelist_bundle_format".localized, bundleID)) } if protectedProcesses.contains(name) { - return .blocked(reason: "\(name) is a protected system process") + return .blocked(reason: String(format: "process_block_protected_format".localized, name)) } if userBlacklist.contains(name) { @@ -75,7 +75,7 @@ public struct ProcessSafetyPolicy: Sendable { } if process.path == nil { - return .needsConfirmation(reason: "\(name) has no path info — proceed with caution") + return .needsConfirmation(reason: String(format: "process_block_no_path_format".localized, name)) } return .allowed diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index 3171227..9434668 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -24,7 +24,7 @@ struct AboutView: View { Text("app_title".localized) .font(.title) .fontWeight(.bold) - Text(String(format: "about_version".localized, Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")) + Text(String(format: "about_version".localized, Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "version_unknown".localized)) .font(.subheadline) .opacity(0.85) } diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index b936ce7..ada7fa1 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -238,7 +238,7 @@ public struct CleanupView: View { Spacer() - Text("\(item.freedMB) MB") + Text(String(format: "cleanup_mb_format".localized, item.freedMB)) .font(.system(.subheadline, design: .monospaced)) .foregroundColor(.green) } @@ -478,7 +478,7 @@ public struct CleanupView: View { Spacer() - Text("\(category.sizeMB) MB") + Text(String(format: "cleanup_mb_format".localized, category.sizeMB)) .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } @@ -501,7 +501,7 @@ public struct CleanupView: View { Button { viewModel.showAllItems(category.id) } label: { - Text("cleanup_show_all_count \(viewModel.remainingCount(category.id))") + Text(String(format: "cleanup_show_all_count".localized, viewModel.remainingCount(category.id))) .font(.caption) .foregroundColor(.accentColor) } @@ -546,7 +546,7 @@ public struct CleanupView: View { .truncationMode(.middle) if let date = item.modificationDate { - Text(date.formatted(.dateTime.day().month().year())) + Text(date.formatted(.dateTime.day().month().year().locale(LanguageManager.shared.currentLocale))) .font(.system(size: 10)) .foregroundColor(.secondary) } @@ -554,7 +554,7 @@ public struct CleanupView: View { Spacer() - Text("\(item.sizeMB) MB") + Text(String(format: "cleanup_mb_format".localized, item.sizeMB)) .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) } @@ -562,7 +562,7 @@ public struct CleanupView: View { } private func riskBadge(for risk: OperationRisk) -> some View { - Text(risk.rawValue.uppercased()) + Text(risk.localizedTitle.uppercased()) .font(.system(size: 9, weight: .bold)) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -740,7 +740,7 @@ public struct CleanupView: View { } } -// Помощник для эффекта размытия (Glassmorphism) +// Glassmorphism blur effect helper struct VisualEffectView: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift index 24a7fb5..d7ab9f1 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardView.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift @@ -26,6 +26,7 @@ struct DashboardView: View { } .padding(24) } + .background(Color(NSColor.controlBackgroundColor)) .task { await viewModel.refresh() } @@ -33,7 +34,7 @@ struct DashboardView: View { private var systemInfoSection: some View { VStack(alignment: .leading, spacing: 16) { - Text("dashboard_system_info".localized) + Label("dashboard_system_info".localized, systemImage: "info.circle") .font(.headline) HStack(spacing: 40) { @@ -44,28 +45,28 @@ struct DashboardView: View { } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(NSColor.windowBackgroundColor)) + .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } } private var diskUsageCard: some View { VStack(alignment: .leading, spacing: 16) { - Text("dashboard_disk_usage".localized) + Label("dashboard_disk_usage".localized, systemImage: "internaldrive") .font(.headline) ZStack { Chart { SectorMark( - angle: .value("Used", viewModel.usedDiskSpace), + angle: .value("dashboard_used".localized, viewModel.usedDiskSpace), innerRadius: .ratio(0.55), angularInset: 2 ) .foregroundStyle(Color.accentColor) SectorMark( - angle: .value("Free", viewModel.freeDiskSpace), + angle: .value("dashboard_free".localized, viewModel.freeDiskSpace), innerRadius: .ratio(0.55), angularInset: 2 ) @@ -74,7 +75,7 @@ struct DashboardView: View { .frame(height: 200) VStack { - Text("\(Int(viewModel.usedDiskPercentage * 100))%") + Text(String(format: "dashboard_used_percent_format".localized, Int(viewModel.usedDiskPercentage * 100))) .font(.system(size: 28, weight: .bold)) .minimumScaleFactor(0.5) Text("dashboard_used".localized) @@ -92,34 +93,32 @@ struct DashboardView: View { } } .padding() - .background(Color(NSColor.windowBackgroundColor)) + .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) - .frame(maxWidth: .infinity) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } private var statsCard: some View { VStack(alignment: .leading, spacing: 16) { - Text("dashboard_statistics".localized) + Label("dashboard_statistics".localized, systemImage: "chart.bar") .font(.headline) VStack(spacing: 20) { - StatRow(title: "dashboard_total_freed".localized, value: ByteCountFormatter.string(fromByteCount: viewModel.totalFreedBytes, countStyle: .file), icon: "trash") + StatRow(title: "dashboard_total_freed".localized, value: ByteCountFormatter.localizedString(fromByteCount: viewModel.totalFreedBytes, countStyle: .file), icon: "trash") StatRow(title: "dashboard_cleanups".localized, value: "\(viewModel.cleanupCount)", icon: "arrow.counterclockwise") StatRow(title: "dashboard_status".localized, value: "dashboard_healthy".localized, icon: "checkmark.circle", color: .green) } Spacer() } .padding() - .background(Color(NSColor.windowBackgroundColor)) + .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) - .frame(maxWidth: .infinity) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } private var recentOperationsSection: some View { VStack(alignment: .leading, spacing: 16) { - Text("dashboard_recent_operations".localized) + Label("dashboard_recent_operations".localized, systemImage: "clock") .font(.headline) if viewModel.recentTransactions.isEmpty { @@ -127,6 +126,9 @@ struct DashboardView: View { .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 40) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } else { VStack(spacing: 0) { ForEach(viewModel.recentTransactions) { transaction in @@ -136,9 +138,9 @@ struct DashboardView: View { } } } - .background(Color(NSColor.windowBackgroundColor)) + .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } } } @@ -179,16 +181,16 @@ struct TransactionRow: View { var body: some View { HStack { VStack(alignment: .leading) { - Text(transaction.timestamp, style: .date) + Text(transaction.timestamp.formatted(.dateTime.year().month().day().locale(LanguageManager.shared.currentLocale))) .fontWeight(.medium) - Text(transaction.timestamp, style: .time) + Text(transaction.timestamp.formatted(.dateTime.hour().minute().locale(LanguageManager.shared.currentLocale))) .font(.caption) .foregroundColor(.secondary) } Spacer() - Text("+\(ByteCountFormatter.string(fromByteCount: totalFreed, countStyle: .file))") + Text(String(format: "dashboard_freed_prefix".localized, ByteCountFormatter.localizedString(fromByteCount: totalFreed, countStyle: .file))) .foregroundColor(.green) .fontWeight(.bold) } @@ -214,7 +216,7 @@ struct DiskStatItem: View { .font(.caption) .foregroundColor(.secondary) } - Text(ByteCountFormatter.string(fromByteCount: value, countStyle: .file)) + Text(ByteCountFormatter.localizedString(fromByteCount: value, countStyle: .file)) .fontWeight(.medium) } } diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift.back b/MacOSCleaner/Features/Dashboard/DashboardView.swift.back new file mode 100644 index 0000000..24a7fb5 --- /dev/null +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift.back @@ -0,0 +1,249 @@ +import SwiftUI +import Charts + +struct DashboardView: View { + @StateObject private var viewModel: DashboardViewModel + + init(journal: TransactionJournal) { + _viewModel = StateObject(wrappedValue: DashboardViewModel(journal: journal)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("dashboard_title".localized) + .font(.largeTitle) + .fontWeight(.bold) + + HStack(alignment: .top, spacing: 20) { + diskUsageCard + statsCard + } + + systemInfoSection + + recentOperationsSection + } + .padding(24) + } + .task { + await viewModel.refresh() + } + } + + private var systemInfoSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("dashboard_system_info".localized) + .font(.headline) + + HStack(spacing: 40) { + SystemInfoItem(title: "dashboard_model".localized, value: viewModel.systemInfo.model, icon: "laptopcomputer") + SystemInfoItem(title: "dashboard_os_version".localized, value: viewModel.systemInfo.osVersion, icon: "info.circle") + SystemInfoItem(title: "dashboard_processor".localized, value: viewModel.systemInfo.processor, icon: "cpu") + SystemInfoItem(title: "dashboard_memory".localized, value: viewModel.systemInfo.memory, icon: "memorychip") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.windowBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + } + } + + private var diskUsageCard: some View { + VStack(alignment: .leading, spacing: 16) { + Text("dashboard_disk_usage".localized) + .font(.headline) + + ZStack { + Chart { + SectorMark( + angle: .value("Used", viewModel.usedDiskSpace), + innerRadius: .ratio(0.55), + angularInset: 2 + ) + .foregroundStyle(Color.accentColor) + + SectorMark( + angle: .value("Free", viewModel.freeDiskSpace), + innerRadius: .ratio(0.55), + angularInset: 2 + ) + .foregroundStyle(Color.secondary.opacity(0.15)) + } + .frame(height: 200) + + VStack { + Text("\(Int(viewModel.usedDiskPercentage * 100))%") + .font(.system(size: 28, weight: .bold)) + .minimumScaleFactor(0.5) + Text("dashboard_used".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 0) { + DiskStatItem(title: "dashboard_used".localized, value: viewModel.usedDiskSpace, color: .accentColor) + Spacer() + DiskStatItem(title: "dashboard_free".localized, value: viewModel.freeDiskSpace, color: .secondary.opacity(0.4)) + Spacer() + DiskStatItem(title: "dashboard_total".localized, value: viewModel.totalDiskSpace, color: nil, alignment: .trailing) + } + } + .padding() + .background(Color(NSColor.windowBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .frame(maxWidth: .infinity) + } + + private var statsCard: some View { + VStack(alignment: .leading, spacing: 16) { + Text("dashboard_statistics".localized) + .font(.headline) + + VStack(spacing: 20) { + StatRow(title: "dashboard_total_freed".localized, value: ByteCountFormatter.string(fromByteCount: viewModel.totalFreedBytes, countStyle: .file), icon: "trash") + StatRow(title: "dashboard_cleanups".localized, value: "\(viewModel.cleanupCount)", icon: "arrow.counterclockwise") + StatRow(title: "dashboard_status".localized, value: "dashboard_healthy".localized, icon: "checkmark.circle", color: .green) + } + Spacer() + } + .padding() + .background(Color(NSColor.windowBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .frame(maxWidth: .infinity) + } + + private var recentOperationsSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("dashboard_recent_operations".localized) + .font(.headline) + + if viewModel.recentTransactions.isEmpty { + Text("dashboard_no_recent_operations".localized) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 40) + } else { + VStack(spacing: 0) { + ForEach(viewModel.recentTransactions) { transaction in + TransactionRow(transaction: transaction) + if transaction.id != viewModel.recentTransactions.last?.id { + Divider() + } + } + } + .background(Color(NSColor.windowBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + } + } + } +} + +struct StatRow: View { + let title: String + let value: String + let icon: String + var color: Color = .accentColor + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(color) + .frame(width: 32) + + VStack(alignment: .leading) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.headline) + } + Spacer() + } + } +} + +struct TransactionRow: View { + let transaction: CleanupTransaction + + var totalFreed: Int64 { + transaction.operations.reduce(0) { $0 + $1.bytesFreed } + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(transaction.timestamp, style: .date) + .fontWeight(.medium) + Text(transaction.timestamp, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text("+\(ByteCountFormatter.string(fromByteCount: totalFreed, countStyle: .file))") + .foregroundColor(.green) + .fontWeight(.bold) + } + .padding() + } +} + +struct DiskStatItem: View { + let title: String + let value: Int64 + let color: Color? + var alignment: HorizontalAlignment = .leading + + var body: some View { + VStack(alignment: alignment) { + HStack(spacing: 4) { + if let color = color { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + Text(ByteCountFormatter.string(fromByteCount: value, countStyle: .file)) + .fontWeight(.medium) + } + } +} + +struct SystemInfoItem: View { + let title: String + let value: String + let icon: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.subheadline) + .fontWeight(.medium) + } + } + } +} + +#Preview { + DashboardView(journal: TransactionJournal()) +} diff --git a/MacOSCleaner/Features/Permissions/PermissionsView.swift b/MacOSCleaner/Features/Permissions/PermissionsView.swift index c9ef7ce..4adbf56 100644 --- a/MacOSCleaner/Features/Permissions/PermissionsView.swift +++ b/MacOSCleaner/Features/Permissions/PermissionsView.swift @@ -65,7 +65,7 @@ struct PermissionsView: View { VStack(alignment: .leading, spacing: 2) { HStack { - Text("Full Disk Access") + Text("permissions.full_disk_access".localized) .font(.headline) Spacer() statusBadge(isGranted: permissionsManager.hasFullDiskAccess) diff --git a/MacOSCleaner/Features/Processes/ProcessRow.swift b/MacOSCleaner/Features/Processes/ProcessRow.swift index 3752231..aadbc41 100644 --- a/MacOSCleaner/Features/Processes/ProcessRow.swift +++ b/MacOSCleaner/Features/Processes/ProcessRow.swift @@ -60,7 +60,7 @@ struct ProcessRow: View { } HStack(spacing: 8) { - Text("PID \(process.pid)") + Text(String(format: "process_pid_format".localized, process.pid)) .font(.caption) .foregroundColor(.secondary) diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index 98f880a..8e09613 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -22,8 +22,6 @@ public struct ProcessesView: View { .padding(.horizontal) .padding(.vertical, 8) - Divider() - if isEditMode { selectionToolbar } @@ -50,7 +48,7 @@ public struct ProcessesView: View { } } } - .background(Color(NSColor.windowBackgroundColor)) + .background(Color(NSColor.controlBackgroundColor)) .alert( "processes_confirm_terminate".localized, isPresented: Binding( @@ -127,17 +125,14 @@ public struct ProcessesView: View { } .padding(.horizontal, 8) .padding(.vertical, 4) - .background( - Capsule() - .fill(Color.red.opacity(0.1)) - ) + .background(Capsule().fill(Color.red.opacity(0.1))) .foregroundColor(.red) } Menu { Picker("view_mode".localized, selection: $viewModel.viewMode) { ForEach(ProcessesViewModel.ViewMode.allCases) { mode in - Text(mode.rawValue).tag(mode) + Text(mode.localizedName).tag(mode) } } Divider() @@ -164,9 +159,7 @@ public struct ProcessesView: View { } .buttonStyle(.bordered) - Button(action: { - viewModel.showBlacklistAlert = true - }) { + Button(action: { viewModel.showBlacklistAlert = true }) { HStack(spacing: 4) { Image(systemName: "xmark.circle") .font(.system(size: 14, weight: .semibold)) @@ -179,9 +172,7 @@ public struct ProcessesView: View { .buttonStyle(.bordered) .help("processes_tooltip_blacklist".localized) - Button(action: { - viewModel.showWhitelistAlert = true - }) { + Button(action: { viewModel.showWhitelistAlert = true }) { HStack(spacing: 4) { Image(systemName: "lock.circle") .font(.system(size: 14, weight: .semibold)) @@ -194,9 +185,7 @@ public struct ProcessesView: View { .buttonStyle(.bordered) .help("processes_tooltip_whitelist".localized) - Button(action: { - Task { await viewModel.scan() } - }) { + Button(action: { Task { await viewModel.scan() } }) { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .semibold)) } @@ -204,42 +193,34 @@ public struct ProcessesView: View { .help("processes_tooltip_refresh".localized) } .padding() - .background(Color(NSColor.controlBackgroundColor).opacity(0.3)) + .background(Color(NSColor.windowBackgroundColor)) } private var selectionToolbar: some View { HStack(spacing: 12) { - Button(action: { - viewModel.selectAll() - }) { + Button(action: viewModel.selectAll) { Label("select_all".localized, systemImage: "checkmark.circle") } .buttonStyle(.bordered) - Button(action: { - viewModel.deselectAll() - }) { + Button(action: viewModel.deselectAll) { Label("deselect_all".localized, systemImage: "circle") } .buttonStyle(.bordered) Spacer() - Text("\(viewModel.selection.count) selected") + Text(String(format: "processes_selected_count".localized, viewModel.selection.count)) .font(.caption) .foregroundColor(.secondary) - Button(action: { - Task { await viewModel.terminateSelected() } - }) { + Button(action: { Task { await viewModel.terminateSelected() } }) { Label("terminate_selected".localized, systemImage: "xmark.circle") } .buttonStyle(.bordered) .disabled(viewModel.selection.isEmpty) - Button(role: .destructive, action: { - Task { await viewModel.forceKillSelected() } - }) { + Button(role: .destructive, action: { Task { await viewModel.forceKillSelected() } }) { Label("force_kill_selected".localized, systemImage: "exclamationmark.triangle") } .buttonStyle(.bordered) @@ -247,19 +228,21 @@ public struct ProcessesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .background(Color(NSColor.windowBackgroundColor)) } private var searchField: some View { HStack { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) + .font(.system(size: 12)) TextField("processes_search".localized, text: $viewModel.searchText) .textFieldStyle(.plain) + .font(.system(size: 13)) } - .padding(6) + .padding(8) .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(6) + .cornerRadius(8) } private var groupedProcessList: some View { @@ -271,6 +254,11 @@ public struct ProcessesView: View { } } .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .padding() } } @@ -283,6 +271,11 @@ public struct ProcessesView: View { } } .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .padding() } } @@ -309,7 +302,7 @@ public struct ProcessesView: View { .fontWeight(.medium) HStack(spacing: 8) { - Text("\(group.processCount) processes") + Text(String(format: "processes_process_count".localized, group.processCount)) .font(.caption) .foregroundColor(.secondary) @@ -334,15 +327,10 @@ public struct ProcessesView: View { Spacer() Menu { - Button(action: { - Task { await viewModel.terminateGroup(group) } - }) { + Button(action: { Task { await viewModel.terminateGroup(group) } }) { Label("processes_terminate_all".localized, systemImage: "xmark.circle") } - - Button(role: .destructive, action: { - Task { await viewModel.forceKillGroup(group) } - }) { + Button(role: .destructive, action: { Task { await viewModel.forceKillGroup(group) } }) { Label("processes_force_kill_all".localized, systemImage: "exclamationmark.triangle") } } label: { @@ -426,7 +414,7 @@ public struct ProcessesView: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) - HStack { + HStack(spacing: 8) { TextField("processes_blacklist_placeholder".localized, text: $viewModel.newBlacklistEntry) .textFieldStyle(.roundedBorder) Button("add".localized) { @@ -442,9 +430,7 @@ public struct ProcessesView: View { Text(name) .font(.system(.body, design: .monospaced)) Spacer() - Button(action: { - Task { await viewModel.removeFromBlacklist(name) } - }) { + Button(action: { Task { await viewModel.removeFromBlacklist(name) } }) { Image(systemName: "minus.circle") .foregroundColor(.red) } @@ -472,7 +458,7 @@ public struct ProcessesView: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) - HStack { + HStack(spacing: 8) { TextField("processes_whitelist_placeholder".localized, text: $viewModel.newWhitelistEntry) .textFieldStyle(.roundedBorder) Button("add".localized) { @@ -488,9 +474,7 @@ public struct ProcessesView: View { Text(name) .font(.system(.body, design: .monospaced)) Spacer() - Button(action: { - Task { await viewModel.removeFromWhitelist(name) } - }) { + Button(action: { Task { await viewModel.removeFromWhitelist(name) } }) { Image(systemName: "minus.circle") .foregroundColor(.red) } @@ -523,9 +507,7 @@ private struct AppIconView: View { .font(.system(size: 18)) .foregroundColor(.secondary) .frame(width: 24) - .task { - await loadIcon() - } + .task { await loadIcon() } } } diff --git a/MacOSCleaner/Features/Processes/ProcessesViewModel.swift b/MacOSCleaner/Features/Processes/ProcessesViewModel.swift index d630f44..d47a58e 100644 --- a/MacOSCleaner/Features/Processes/ProcessesViewModel.swift +++ b/MacOSCleaner/Features/Processes/ProcessesViewModel.swift @@ -37,6 +37,13 @@ public final class ProcessesViewModel { case flat = "Flat" public var id: String { rawValue } + + public var localizedName: String { + switch self { + case .grouped: return "processes_view_mode_grouped".localized + case .flat: return "processes_view_mode_flat".localized + } + } } public var filteredProcesses: [RunningProcess] { @@ -97,7 +104,7 @@ public final class ProcessesViewModel { } public var totalMemoryFormatted: String { - ByteCountFormatter.string(fromByteCount: Int64(totalMemoryUsed), countStyle: .memory) + ByteCountFormatter.localizedString(fromByteCount: Int64(totalMemoryUsed), countStyle: .memory) } public init(processManager: ProcessManager = ProcessManager()) { diff --git a/MacOSCleaner/Features/Settings/AppSettings.swift b/MacOSCleaner/Features/Settings/AppSettings.swift index 65be9a4..72309d6 100644 --- a/MacOSCleaner/Features/Settings/AppSettings.swift +++ b/MacOSCleaner/Features/Settings/AppSettings.swift @@ -5,14 +5,16 @@ public enum AppLanguage: String, CaseIterable, Identifiable { case english = "en" case russian = "ru" case ukrainian = "uk" + case spanish = "es" public var id: String { rawValue } public var displayName: String { switch self { - case .english: return "English" - case .russian: return "Русский" - case .ukrainian: return "Українська" + case .english: return "language.english".localized + case .russian: return "language.russian".localized + case .ukrainian: return "language.ukrainian".localized + case .spanish: return "language.spanish".localized } } } @@ -101,7 +103,6 @@ public final class AppSettings { static let bypassTrashOnUninstall = "settings_bypassTrashOnUninstall" static let showRelatedFiles = "settings_showRelatedFiles" static let emptyTrashImmediately = "settings_emptyTrashImmediately" - static let skipExpertMode = "settings_skipExpertMode" static let processRefreshInterval = "settings_processRefreshInterval" static let processSortOption = "settings_processSortOption" } @@ -161,10 +162,6 @@ public final class AppSettings { didSet { UserDefaults.standard.set(emptyTrashImmediately, forKey: Keys.emptyTrashImmediately) } } - public var skipExpertMode: Bool { - didSet { UserDefaults.standard.set(skipExpertMode, forKey: Keys.skipExpertMode) } - } - // MARK: - Process Management public var processRefreshInterval: RefreshInterval { @@ -194,7 +191,6 @@ public final class AppSettings { self.bypassTrashOnUninstall = defaults.bool(forKey: Keys.bypassTrashOnUninstall) self.showRelatedFiles = defaults.object(forKey: Keys.showRelatedFiles) as? Bool ?? true self.emptyTrashImmediately = defaults.bool(forKey: Keys.emptyTrashImmediately) - self.skipExpertMode = defaults.bool(forKey: Keys.skipExpertMode) self.processRefreshInterval = RefreshInterval(rawValue: defaults.string(forKey: Keys.processRefreshInterval) ?? "") ?? .manual self.processSortOption = ProcessSortOption(rawValue: defaults.string(forKey: Keys.processSortOption) ?? "") ?? .cpu @@ -212,7 +208,7 @@ public final class AppSettings { let allKeys = [ Keys.language, Keys.theme, Keys.showNotifications, Keys.showTooltips, Keys.autoScanOnStartup, Keys.emptyTrashDuringCleanup, Keys.bypassTrashOnUninstall, - Keys.showRelatedFiles, Keys.emptyTrashImmediately, Keys.skipExpertMode, + Keys.showRelatedFiles, Keys.emptyTrashImmediately, Keys.processRefreshInterval, Keys.processSortOption ] for key in allKeys { @@ -228,7 +224,6 @@ public final class AppSettings { bypassTrashOnUninstall = false showRelatedFiles = true emptyTrashImmediately = false - skipExpertMode = false processRefreshInterval = .manual processSortOption = .cpu diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift index 3073324..82715a2 100644 --- a/MacOSCleaner/Features/Settings/SettingsView.swift +++ b/MacOSCleaner/Features/Settings/SettingsView.swift @@ -1,6 +1,8 @@ import SwiftUI import UserNotifications +// MARK: - SettingsView + struct SettingsView: View { @Bindable var settings: AppSettings let permissionsManager: PermissionsManager @@ -10,22 +12,16 @@ struct SettingsView: View { @State private var notificationStatus: UNAuthorizationStatus = .notDetermined var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - header - .padding(.bottom, 8) - - sectionCard { permissionsSection } - sectionCard { generalSection } - sectionCard { processesSection } - sectionCard { startupSection } - sectionCard { trashDeletionSection } - sectionCard { advancedSection } - sectionCard { resetSection } - } - .padding(32) - .frame(maxWidth: .infinity, alignment: .leading) + Form { + permissionsSection + generalSection + processesSection + startupSection + trashDeletionSection + advancedSection + resetSection } + .formStyle(.grouped) .confirmationDialog( "settings_reset_confirm_title".localized, isPresented: $showResetConfirmation, @@ -41,275 +37,187 @@ struct SettingsView: View { } } - // MARK: - Header - - private var header: some View { - HStack(spacing: 12) { - Image(systemName: "gear") - .font(.system(size: 32)) - .foregroundColor(.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text("settings_title".localized) - .font(.largeTitle) - .fontWeight(.bold) - Text("settings_subtitle".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - // MARK: - Permissions private var permissionsSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_permissions".localized, icon: "lock.shield") - - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Full Disk Access") - .font(.body) + Section { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("permissions.full_disk_access".localized) Text("settings_fda_description".localized) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } - Spacer() - - HStack(spacing: 8) { - statusBadge(isGranted: permissionsManager.hasFullDiskAccess) + HStack(spacing: 6) { + if permissionsManager.hasFullDiskAccess { + Label("permissions_status_granted".localized, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("permissions_status_required".localized, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + } Button { permissionsManager.openFullDiskAccessSettings() } label: { - Label("settings_open_settings".localized, systemImage: "arrow.up.right") + Image(systemName: "arrow.up.forward.app") } - .buttonStyle(.bordered) - .controlSize(.small) + .buttonStyle(.plain) + .foregroundStyle(.secondary) } + .font(.caption) + .labelStyle(.iconOnly) } - .padding(.horizontal, 12) - .padding(.vertical, 8) Button { permissionsManager.refresh() } label: { Label("settings_check_permissions".localized, systemImage: "arrow.clockwise") } - .buttonStyle(.bordered) - .controlSize(.small) - .padding(.horizontal, 12) + .buttonStyle(.plain) + } header: { + Label("settings_permissions".localized, systemImage: "lock.shield") } } // MARK: - General private var generalSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_general".localized, icon: "gearshape") - - settingRow( - title: "settings_language".localized, - tooltip: "settings_tooltip_language".localized - ) { - Picker("", selection: $settings.language) { - ForEach(AppLanguage.allCases) { lang in - Text(lang.displayName).tag(lang) - } + Section { + Picker("settings_language".localized, selection: $settings.language) { + ForEach(AppLanguage.allCases) { lang in + Text(lang.displayName).tag(lang) } - .labelsHidden() - .frame(width: 160) } + .tooltip("settings_tooltip_language".localized, enabled: settings.showTooltips) - settingRow( - title: "settings_theme".localized, - tooltip: "settings_tooltip_theme".localized - ) { - Picker("", selection: $settings.theme) { - ForEach(AppTheme.allCases) { theme in - Text(theme.localizedName).tag(theme) - } + Picker("settings_theme".localized, selection: $settings.theme) { + ForEach(AppTheme.allCases) { theme in + Text(theme.localizedName).tag(theme) } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 220) } + .pickerStyle(.segmented) + .tooltip("settings_tooltip_theme".localized, enabled: settings.showTooltips) - settingToggle( - title: "settings_notifications".localized, - isOn: $settings.showNotifications, - tooltip: "settings_tooltip_notifications".localized - ) + Toggle("settings_notifications".localized, isOn: $settings.showNotifications) + .tooltip("settings_tooltip_notifications".localized, enabled: settings.showTooltips) if settings.showNotifications { notificationStatusView - .onAppear { - updateNotificationStatus() - } - .onChange(of: settings.showNotifications) { _, _ in - updateNotificationStatus() - } } - settingToggle( - title: "settings_tooltips".localized, - isOn: $settings.showTooltips, - tooltip: "settings_tooltip_tooltips".localized - ) - - settingToggle( - title: "settings_auto_scan".localized, - isOn: $settings.autoScanOnStartup, - tooltip: "settings_tooltip_auto_scan".localized - ) + Toggle("settings_tooltips".localized, isOn: $settings.showTooltips) + .tooltip("settings_tooltip_tooltips".localized, enabled: settings.showTooltips) + + Toggle("settings_auto_scan".localized, isOn: $settings.autoScanOnStartup) + .tooltip("settings_tooltip_auto_scan".localized, enabled: settings.showTooltips) + } header: { + Label("settings_general".localized, systemImage: "gearshape") } } // MARK: - Processes private var processesSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_processes".localized, icon: "cpu") - - settingRow( - title: "settings_refresh_interval".localized, - tooltip: "settings_tooltip_refresh_interval".localized - ) { - Picker("", selection: $settings.processRefreshInterval) { - ForEach(RefreshInterval.allCases) { interval in - Text(interval.localizedName).tag(interval) - } + Section { + Picker("settings_refresh_interval".localized, selection: $settings.processRefreshInterval) { + ForEach(RefreshInterval.allCases) { interval in + Text(interval.localizedName).tag(interval) } - .labelsHidden() - .frame(width: 160) } + .tooltip("settings_tooltip_refresh_interval".localized, enabled: settings.showTooltips) - settingRow( - title: "settings_sort_by".localized, - tooltip: "settings_tooltip_sort_by".localized - ) { - Picker("", selection: $settings.processSortOption) { - ForEach(ProcessSortOption.allCases) { option in - Text(option.localizedName).tag(option) - } + Picker("settings_sort_by".localized, selection: $settings.processSortOption) { + ForEach(ProcessSortOption.allCases) { option in + Text(option.localizedName).tag(option) } - .labelsHidden() - .frame(width: 160) } + .tooltip("settings_tooltip_sort_by".localized, enabled: settings.showTooltips) + } header: { + Label("settings_processes".localized, systemImage: "cpu") } } // MARK: - Startup private var startupSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_startup".localized, icon: "bolt.horizontal.circle") + Section { StartupVendorSettingsView() + } header: { + Label("settings_startup".localized, systemImage: "bolt.horizontal.circle") } } // MARK: - Trash & Deletion private var trashDeletionSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_trash_deletion".localized, icon: "trash") - + Section { HStack { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) + .foregroundStyle(.yellow) Text("settings_trash_warning".localized) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } - .padding(12) - .background(Color.orange.opacity(0.1)) - .cornerRadius(8) - - settingToggle( - title: "settings_empty_trash_during_cleanup".localized, - isOn: $settings.emptyTrashDuringCleanup, - tooltip: "settings_tooltip_empty_trash".localized - ) - .onChange(of: settings.emptyTrashDuringCleanup) { _, newValue in - if newValue { - Task { - do { - try await trashManager.requestTrashAccess() - } catch { - settings.emptyTrashDuringCleanup = false + + Toggle("settings_empty_trash_during_cleanup".localized, isOn: $settings.emptyTrashDuringCleanup) + .tooltip("settings_tooltip_empty_trash".localized, enabled: settings.showTooltips) + .onChange(of: settings.emptyTrashDuringCleanup) { _, newValue in + if newValue { + Task { + do { + try await trashManager.requestTrashAccess() + } catch { + settings.emptyTrashDuringCleanup = false + } } } } - } - settingToggle( - title: "settings_bypass_trash_on_uninstall".localized, - isOn: $settings.bypassTrashOnUninstall, - tooltip: "settings_tooltip_bypass_trash".localized - ) - - settingToggle( - title: "settings_empty_trash_immediately".localized, - isOn: $settings.emptyTrashImmediately, - tooltip: "settings_tooltip_empty_trash_immediately".localized - ) + Toggle("settings_bypass_trash_on_uninstall".localized, isOn: $settings.bypassTrashOnUninstall) + .tooltip("settings_tooltip_bypass_trash".localized, enabled: settings.showTooltips) + + Toggle("settings_empty_trash_immediately".localized, isOn: $settings.emptyTrashImmediately) + .tooltip("settings_tooltip_empty_trash_immediately".localized, enabled: settings.showTooltips) + } header: { + Label("settings_trash_deletion".localized, systemImage: "trash") } } // MARK: - Advanced private var advancedSection: some View { - VStack(alignment: .leading, spacing: 16) { - sectionHeader(title: "settings_advanced".localized, icon: "wrench.and.screwdriver") - - settingToggle( - title: "settings_show_related".localized, - isOn: $settings.showRelatedFiles, - tooltip: "settings_tooltip_show_related".localized - ) - - settingToggle( - title: "settings_skip_expert".localized, - isOn: $settings.skipExpertMode, - tooltip: "settings_tooltip_skip_expert".localized - ) + Section { + Toggle("settings_show_related".localized, isOn: $settings.showRelatedFiles) + .tooltip("settings_tooltip_show_related".localized, enabled: settings.showTooltips) + } header: { + Label("settings_advanced".localized, systemImage: "wrench.and.screwdriver") } } - // MARK: - Reset / Forget Everything + // MARK: - Reset private var resetSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader(title: "settings_data".localized, icon: "arrow.counterclockwise") - - HStack { - VStack(alignment: .leading, spacing: 4) { + Section { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { Text("settings_forget_everything".localized) - .font(.body) Text("settings_forget_description".localized) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } - Spacer() - - Button(role: .destructive) { + Button("settings_reset_button".localized, role: .destructive) { showResetConfirmation = true - } label: { - Text("settings_reset_button".localized) } - .conditionalHelp( - "settings_tooltip_forget".localized, - enabled: settings.showTooltips - ) + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.small) + .tooltip("settings_tooltip_forget".localized, enabled: settings.showTooltips) } - .padding(12) - .background(Color.red.opacity(0.05)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.red.opacity(0.15), lineWidth: 1) - ) + } header: { + Label("settings_data".localized, systemImage: "arrow.counterclockwise") } } @@ -318,52 +226,45 @@ struct SettingsView: View { private var notificationStatusView: some View { HStack { Text("settings_notifications_status".localized) - .font(.body) - .foregroundColor(.secondary) - + .foregroundStyle(.secondary) Spacer() - - HStack(spacing: 8) { - statusBadge + HStack(spacing: 6) { + Group { + switch notificationStatus { + case .authorized: + Label("settings_notifications_granted".localized, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .denied: + Label("settings_notifications_denied".localized, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + case .notDetermined: + Label("settings_notifications_not_determined".localized, systemImage: "questionmark.circle.fill") + .foregroundStyle(.orange) + case .provisional: + Label("permissions.notification_provisional".localized, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + case .ephemeral: + Label("permissions.notification_ephemeral".localized, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + @unknown default: + Label("permissions.unknown_status".localized, systemImage: "questionmark.circle.fill") + .foregroundStyle(.gray) + } + } + .font(.caption) + .labelStyle(.iconOnly) if notificationStatus == .denied { Button("settings_open_notification_settings".localized) { NotificationManager.shared.openNotificationSettings() } - .buttonStyle(.bordered) - .controlSize(.small) + .buttonStyle(.plain) + .font(.caption) } } } - .padding(.horizontal, 12) - .padding(.vertical, 4) - } - - private var statusBadge: some View { - Group { - switch notificationStatus { - case .authorized: - Label("settings_notifications_granted".localized, systemImage: "checkmark.circle.fill") - .foregroundColor(.green) - case .denied: - Label("settings_notifications_denied".localized, systemImage: "xmark.circle.fill") - .foregroundColor(.red) - case .notDetermined: - Label("settings_notifications_not_determined".localized, systemImage: "questionmark.circle.fill") - .foregroundColor(.orange) - case .provisional: - Label("Provisional", systemImage: "exclamationmark.circle.fill") - .foregroundColor(.orange) - case .ephemeral: - Label("Ephemeral", systemImage: "exclamationmark.circle.fill") - .foregroundColor(.orange) - @unknown default: - Label("Unknown", systemImage: "questionmark.circle.fill") - .foregroundColor(.gray) - } - } - .font(.caption) - .labelStyle(.titleAndIcon) + .onAppear { updateNotificationStatus() } + .onChange(of: settings.showNotifications) { _, _ in updateNotificationStatus() } } private func updateNotificationStatus() { @@ -374,76 +275,13 @@ struct SettingsView: View { } } } - - // MARK: - Components - - private func sectionCard(@ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 0) { - content() - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) - } - - private func sectionHeader(title: String, icon: String) -> some View { - Label(title, systemImage: icon) - .font(.headline) - .foregroundColor(.primary) - } - - private func settingRow( - title: String, - tooltip: String, - @ViewBuilder content: () -> Content - ) -> some View { - HStack { - Text(title) - .font(.body) - Spacer() - content() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .conditionalHelp(tooltip, enabled: settings.showTooltips) - } - - private func settingToggle( - title: String, - isOn: Binding, - tooltip: String - ) -> some View { - Toggle(title, isOn: isOn) - .toggleStyle(.switch) - .padding(.horizontal, 12) - .padding(.vertical, 4) - .conditionalHelp(tooltip, enabled: settings.showTooltips) - } - - private func statusBadge(isGranted: Bool) -> some View { - HStack(spacing: 4) { - Circle() - .fill(isGranted ? Color.green : Color.orange) - .frame(width: 8, height: 8) - - Text(isGranted ? "permissions_status_granted".localized : "permissions_status_required".localized) - .font(.caption2) - .fontWeight(.medium) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(isGranted ? Color.green.opacity(0.1) : Color.orange.opacity(0.1)) - .cornerRadius(6) - } } -// MARK: - Conditional Tooltip Modifier +// MARK: - Conditional Tooltip private extension View { @ViewBuilder - func conditionalHelp(_ text: String, enabled: Bool) -> some View { + func tooltip(_ text: String, enabled: Bool) -> some View { if enabled { self.help(text) } else { diff --git a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift index 5acf540..47d1949 100644 --- a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift +++ b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift @@ -27,12 +27,8 @@ public struct StartupServicesView: View { serviceList } } - .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - Task { - await viewModel.scan() - } - } + .background(Color(NSColor.controlBackgroundColor)) + .onAppear { Task { await viewModel.scan() } } } private var header: some View { @@ -47,9 +43,7 @@ public struct StartupServicesView: View { .foregroundColor(.secondary) } Spacer() - Button(action: { - Task { await viewModel.scan() } - }) { + Button(action: { Task { await viewModel.scan() } }) { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .semibold)) } @@ -60,18 +54,14 @@ public struct StartupServicesView: View { filterPicker } .padding() - .background(Color(NSColor.controlBackgroundColor)) - .overlay(alignment: .bottom) { - Divider() - } + .background(Color(NSColor.windowBackgroundColor)) } private var filterPicker: some View { HStack(spacing: 8) { filterButton(title: "startup_filter_all".localized, tag: nil, count: viewModel.services.count) - Divider() - .frame(height: 16) + Divider().frame(height: 16) filterButton( title: "startup_category_user".localized, @@ -154,13 +144,10 @@ public struct StartupServicesView: View { LazyVStack(spacing: 0) { ForEach(viewModel.filteredServices) { service in ServiceRow(service: service) { - Task { - await viewModel.toggle(service: service) - } + Task { await viewModel.toggle(service: service) } } if service.id != viewModel.filteredServices.last?.id { - Divider() - .padding(.leading, 120) + Divider().padding(.leading, 120) } } } @@ -168,7 +155,7 @@ public struct StartupServicesView: View { .padding(.vertical, 8) .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) .padding() } } @@ -178,7 +165,6 @@ public struct StartupServicesView: View { Image(systemName: "bolt.horizontal.circle") .font(.system(size: 64, weight: .thin)) .foregroundColor(.secondary.opacity(0.5)) - VStack(spacing: 8) { Text("startup_no_agents".localized) .font(.headline) @@ -195,7 +181,6 @@ public struct StartupServicesView: View { Image(systemName: "exclamationmark.triangle") .font(.system(size: 64, weight: .thin)) .foregroundColor(.orange) - VStack(spacing: 8) { Text("startup_scan_failed".localized) .font(.headline) @@ -205,7 +190,6 @@ public struct StartupServicesView: View { .multilineTextAlignment(.center) .padding(.horizontal, 40) } - Button("try_again".localized) { Task { await viewModel.scan() } } @@ -261,10 +245,7 @@ struct CategoryBadge: View { } .padding(.horizontal, 8) .padding(.vertical, 4) - .background( - Capsule() - .fill(category.color.opacity(0.12)) - ) + .background(Capsule().fill(category.color.opacity(0.12))) .foregroundColor(category.color) .help(categoryHelpText) .frame(minWidth: 90) @@ -272,12 +253,9 @@ struct CategoryBadge: View { private var categoryHelpText: String { switch category { - case .user: - return "startup_help_user".localized - case .thirdParty: - return "startup_help_third_party".localized - case .system: - return "startup_help_system".localized + case .user: return "startup_help_user".localized + case .thirdParty: return "startup_help_third_party".localized + case .system: return "startup_help_system".localized } } } diff --git a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift new file mode 100644 index 0000000..1ccd736 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift @@ -0,0 +1,55 @@ +import Foundation + +public actor AppDiscovery { + private let fileManager: FileManager + private let commandRunner: CommandRunner + + public init(fileManager: FileManager = .default, commandRunner: CommandRunner = CommandRunner()) { + self.fileManager = fileManager + self.commandRunner = commandRunner + } + + public func findAll() async -> [URL] { + var urls: [URL] = [] + + // Standard app directories + let appDirs = [ + URL(fileURLWithPath: "/Applications"), + fileManager.urls(for: .applicationDirectory, in: .userDomainMask).first, + URL(fileURLWithPath: "\(NSHomeDirectory())/Applications"), + ].compactMap { $0 } + + for dir in appDirs { + if let contents = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { + urls.append(contentsOf: contents.filter { $0.pathExtension == "app" }) + } + } + + // Application Support (Google Updater, etc.) + let appSupport = "\(NSHomeDirectory())/Library/Application Support" + if let result = try? await commandRunner.run( + command: "/usr/bin/find", + arguments: [appSupport, "-maxdepth", "4", "-name", "*.app", "-type", "d", "-prune"] + ) { + for path in result.stdout.components(separatedBy: .newlines) where !path.isEmpty { + urls.append(URL(fileURLWithPath: path)) + } + } + + // Dev build products (DerivedData) + let derivedData = "\(NSHomeDirectory())/Library/Developer/Xcode/DerivedData" + if let result = try? await commandRunner.run( + command: "/usr/bin/find", + arguments: [derivedData, "-maxdepth", "5", "-name", "*.app", "-type", "d", "-prune"] + ) { + for path in result.stdout.components(separatedBy: .newlines) where !path.isEmpty { + let url = URL(fileURLWithPath: path) + if !url.path.contains("/Applications/") { + urls.append(url) + } + } + } + + return Array(Set(urls)) + } +} diff --git a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift new file mode 100644 index 0000000..ef5261d --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift @@ -0,0 +1,195 @@ +import Foundation + +public struct AppIdentity: Sendable, Hashable { + public let bundleID: String + public let appName: String + public let bundleName: String? + public let bundleVersion: String? + public let executableName: String + public let teamID: String? + public let signingAuthority: String? + public let bundleURL: URL + public let isAppStore: Bool + public let isSandboxed: Bool + public let isAdHocSigned: Bool + + public let vendorNames: Set + public let helperNames: Set + public let frameworkNames: Set + public let xpcServiceNames: Set + public let plugInNames: Set + + public let isElectron: Bool + public let isJetBrains: Bool + public let isFlutter: Bool + public let isJava: Bool + public let isQt: Bool + public let isDocker: Bool +} + +public extension AppIdentity { + static func resolve(from url: URL, commandRunner: CommandRunner = CommandRunner()) async -> AppIdentity { + let bundle = Bundle(url: url) + let bundleID = bundle?.bundleIdentifier ?? "unknown.\(url.deletingPathExtension().lastPathComponent)" + let appName = url.deletingPathExtension().lastPathComponent + let bundleName = bundle?.infoDictionary?["CFBundleName"] as? String + let bundleVersion = bundle?.infoDictionary?["CFBundleShortVersionString"] as? String + let executableName = bundle?.infoDictionary?["CFBundleExecutable"] as? String ?? appName + + let teamID = await extractTeamID(url: url, commandRunner: commandRunner) + let authority = await extractSigningAuthority(url: url, commandRunner: commandRunner) + let isAdHoc = authority?.contains("Ad Hoc") == true || authority?.contains("not signed") == true + let isAppStore = fileExists(at: url.appendingPathComponent("Contents/_MASReceipt")) + let isSandboxed = await checkSandbox(url: url, commandRunner: commandRunner) + + let frameworkNames = await scanFrameworks(url: url) + let xpcServiceNames = await scanXPCServices(url: url) + let plugInNames = await scanPlugIns(url: url) + + let isElectron = frameworkNames.contains { $0.lowercased().contains("electron") } + let isFlutter = frameworkNames.contains { $0.lowercased().contains("flutter") } + || frameworkNames.contains { $0.lowercased().contains("dart") } + let isJava = plugInNames.contains { $0.lowercased().contains("jdk") || $0.lowercased().contains("jre") } + let isQt = frameworkNames.contains { $0.lowercased().hasPrefix("qt") } + let isDocker = bundleID.lowercased() == "com.docker.docker" + || bundleID.lowercased() == "com.docker.orbstack" + || url.path.lowercased().contains("orbstack") + let isJetBrains = bundleID.lowercased().contains("jetbrains") + || authority?.lowercased().contains("jetbrains") == true + + let helperNames = await scanHelpers(url: url) + let vendorNames = deriveVendorNames(bundleID: bundleID, appName: appName, authority: authority) + + return AppIdentity( + bundleID: bundleID, + appName: appName, + bundleName: bundleName, + bundleVersion: bundleVersion, + executableName: executableName, + teamID: teamID, + signingAuthority: authority, + bundleURL: url, + isAppStore: isAppStore, + isSandboxed: isSandboxed, + isAdHocSigned: isAdHoc, + vendorNames: vendorNames, + helperNames: helperNames, + frameworkNames: frameworkNames, + xpcServiceNames: xpcServiceNames, + plugInNames: plugInNames, + isElectron: isElectron, + isJetBrains: isJetBrains, + isFlutter: isFlutter, + isJava: isJava, + isQt: isQt, + isDocker: isDocker + ) + } +} + +// MARK: - Private helpers + +private func extractTeamID(url: URL, commandRunner: CommandRunner) async -> String? { + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-dv", "--verbose=4", url.path]) + guard let output = result?.stderr else { return nil } + if let range = output.range(of: "TeamIdentifier=") { + let start = range.upperBound + let end = output[start...].firstIndex(where: { $0.isWhitespace || $0.isNewline }) ?? output.endIndex + return String(output[start.. String? { + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-dv", "--verbose=4", url.path]) + guard let output = result?.stderr else { return nil } + if let range = output.range(of: "Authority=") { + let start = range.upperBound + let end = output[start...].firstIndex(where: { $0.isWhitespace || $0.isNewline }) ?? output.endIndex + return String(output[start.. Bool { + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-d", "--entitlements", ":-", url.path]) + guard let output = result?.stdout else { return false } + return output.contains("com.apple.security.app-sandbox") +} + +private func fileExists(at path: URL) -> Bool { + FileManager.default.fileExists(atPath: path.path) +} + +private func scanFrameworks(url: URL) async -> Set { + let fm = FileManager.default + let frameworksURL = url.appendingPathComponent("Contents/Frameworks") + guard let items = try? fm.contentsOfDirectory(at: frameworksURL, includingPropertiesForKeys: nil) else { return [] } + return Set(items.compactMap { $0.pathExtension == "framework" ? $0.deletingPathExtension().lastPathComponent : nil }) +} + +private func scanXPCServices(url: URL) async -> Set { + let fm = FileManager.default + let xpcURL = url.appendingPathComponent("Contents/XPCServices") + guard let items = try? fm.contentsOfDirectory(at: xpcURL, includingPropertiesForKeys: nil) else { return [] } + return Set(items.compactMap { $0.pathExtension == "xpc" ? $0.deletingPathExtension().lastPathComponent : nil }) +} + +private func scanPlugIns(url: URL) async -> Set { + let fm = FileManager.default + let plugInsURL = url.appendingPathComponent("Contents/PlugIns") + guard let items = try? fm.contentsOfDirectory(at: plugInsURL, includingPropertiesForKeys: nil) else { return [] } + return Set(items.compactMap { $0.pathExtension == "bundle" ? $0.deletingPathExtension().lastPathComponent : $0.lastPathComponent }) +} + +private func scanHelpers(url: URL) async -> Set { + let fm = FileManager.default + let frameworksURL = url.appendingPathComponent("Contents/Frameworks") + guard let items = try? fm.contentsOfDirectory(at: frameworksURL, includingPropertiesForKeys: nil) else { return [] } + let helperNames = items.filter { $0.lastPathComponent.lowercased().contains("helper") || $0.lastPathComponent.lowercased().contains("framework") } + return Set(helperNames.map { $0.deletingPathExtension().lastPathComponent }) +} + +private let vendorStopwords: Set = ["com", "org", "net", "io", "app", "co", "inc", "ltd", "llc", "uk", "us", "eu"] + +private func deriveVendorNames(bundleID: String, appName: String, authority: String?) -> Set { + var names = Set() + let parts = bundleID.components(separatedBy: ".") + + for p in parts where p.count >= 4 && !vendorStopwords.contains(p.lowercased()) { + names.insert(p) + names.insert(p.capitalized) + names.insert(p.prefix(1).uppercased() + p.dropFirst()) + } + if let first = parts.first(where: { !vendorStopwords.contains($0.lowercased()) }) { + names.insert(first.capitalized) + } + + if !appName.isEmpty { + names.insert(appName) + names.insert(appName.replacingOccurrences(of: " ", with: "")) + } + if let auth = authority { + let orgRegex = try? NSRegularExpression(pattern: "(?<=: )[^,]+") + if let match = orgRegex?.firstMatch(in: auth, range: NSRange(auth.startIndex..., in: auth)) { + let org = String(auth[Range(match.range, in: auth)!]).trimmingCharacters(in: .whitespaces) + names.insert(org) + } + } + + let staticAliases: [String: Set] = [ + "jetbrains": ["JetBrains", "IntelliJ", "PyCharm", "DataGrip", "GoLand", "WebStorm", "RubyMine", "CLion", "Rider", "AppCode"], + "adobe": ["Adobe"], + "microsoft": ["Microsoft", "Office", "Teams", "VS", "Code"], + "google": ["Google"], + "docker": ["Docker"], + "oracle": ["Oracle"], + ] + for (key, aliases) in staticAliases { + if bundleID.lowercased().contains(key) || appName.lowercased().contains(key) { + names.formUnion(aliases) + } + } + + return names +} diff --git a/MacOSCleaner/Features/Uninstaller/ApplicationRuleRegistry.swift b/MacOSCleaner/Features/Uninstaller/ApplicationRuleRegistry.swift new file mode 100644 index 0000000..1072bc2 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/ApplicationRuleRegistry.swift @@ -0,0 +1,67 @@ +import Foundation + +public actor ApplicationRuleRegistry { + public static let shared = ApplicationRuleRegistry() + private var rules: [ApplicationRule] = [] + + public init() {} + + public init(rules: [ApplicationRule]) { + self.rules = rules + } + + public static func createDefault() -> ApplicationRuleRegistry { + ApplicationRuleRegistry(rules: [ + // Category Rules + CloudStorageRule(), + VirtualizationRule(), + DatabaseToolsRule(), + TerminalRule(), + CommunicationRule(), + GitClientsRule(), + // Individual High-Impact Rules + ParallelsRule(), + VMwareFusionRule(), + DaVinciResolveRule(), + LogicProRule(), + FinalCutProRule(), + RancherDesktopRule(), + KarabinerElementsRule(), + LittleSnitchRule(), + NordVPNRule(), + AlfredRule(), + RaycastRule(), + // Existing Rules + ElectronRule(), BrowserRule(), JetBrainsRule(), + DockerRule(), XcodeRule(), AndroidStudioRule(), + AdobeRule(), MicrosoftOfficeRule(), SteamRule(), + EpicGamesRule(), UnityRule(), HomebrewRule(), + NetworkExtensionRule(), + ]) + } + + public func register(_ rule: ApplicationRule) { + rules.append(rule) + } + + public func registerAll(_ newRules: [ApplicationRule]) { + rules.append(contentsOf: newRules) + } + + public func bestRule(for identity: AppIdentity) -> ApplicationRule { + for rule in rules { + if rule.matches(identity: identity) { + return rule + } + } + return DefaultRule() + } + + public func allMatchingRules(for identity: AppIdentity) -> [ApplicationRule] { + rules.filter { $0.matches(identity: identity) } + } + + public func hasRule(for identity: AppIdentity) -> Bool { + rules.contains { $0.matches(identity: identity) } + } +} \ No newline at end of file diff --git a/MacOSCleaner/Features/Uninstaller/ArtifactClassifier.swift b/MacOSCleaner/Features/Uninstaller/ArtifactClassifier.swift new file mode 100644 index 0000000..f711b4a --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/ArtifactClassifier.swift @@ -0,0 +1,97 @@ +import Foundation + +// MARK: — ArtifactCategory + +public enum ArtifactCategory: Hashable, Sendable { + case related + case developer + case ignored +} + +// MARK: — ClassifiedArtifact + +public struct ClassifiedArtifact: Hashable, Sendable { + public let artifact: ScoredArtifact + public let category: ArtifactCategory + + public init(artifact: ScoredArtifact, category: ArtifactCategory) { + self.artifact = artifact + self.category = category + } +} + +// MARK: — ArtifactClassifier + +public enum ArtifactClassifier { + public static let developerMarkers: Set = [ + "DerivedData", "CoreSimulator", "Archives", + "Android/sdk", ".gradle", ".android", + ".pub-cache", ".dart_tool", + "Docker.raw", "Docker.qcow2", + "steamapps/compatdata", "steamapps/shadercache", + "VaultCache", "PackageCache", + ] + + public static func classify( + _ artifact: ScoredArtifact, + thresholds: ScoreThresholds = .default + ) -> ArtifactCategory { + if hasNegativeEvidence(artifact) { + return .ignored + } + + if artifact.score >= thresholds.guaranteed { + if isDeveloperArtifact(artifact) { + return .developer + } + return .related + } + + if artifact.score >= thresholds.veryLikely { + if isDeveloperArtifact(artifact) { + return .developer + } + return .related + } + + if artifact.score >= thresholds.possible { + if isDeveloperArtifact(artifact) { + return .developer + } + return .related + } + + return .ignored + } + + private static func hasNegativeEvidence(_ artifact: ScoredArtifact) -> Bool { + artifact.evidence.contains { e in + e.source == .foreignBundleID || e.source == .foreignTeamID || e.source == .systemArtifact + } + } + + public static func classifyBatch( + _ artifacts: [ScoredArtifact], + thresholds: ScoreThresholds = .default + ) -> (related: [ClassifiedArtifact], developer: [ClassifiedArtifact], ignored: [ClassifiedArtifact]) { + var related: [ClassifiedArtifact] = [] + var developer: [ClassifiedArtifact] = [] + var ignored: [ClassifiedArtifact] = [] + + for a in artifacts { + let c = ClassifiedArtifact(artifact: a, category: classify(a, thresholds: thresholds)) + switch c.category { + case .related: related.append(c) + case .developer: developer.append(c) + case .ignored: ignored.append(c) + } + } + + return (related, developer, ignored) + } + + private static func isDeveloperArtifact(_ artifact: ScoredArtifact) -> Bool { + let path = artifact.url.path.lowercased() + return developerMarkers.contains(where: { path.contains($0.lowercased()) }) + } +} diff --git a/MacOSCleaner/Features/Uninstaller/BackgroundItemsReader.swift b/MacOSCleaner/Features/Uninstaller/BackgroundItemsReader.swift new file mode 100644 index 0000000..0d75e5d --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/BackgroundItemsReader.swift @@ -0,0 +1,45 @@ +import Foundation + +public actor BackgroundItemsReader { + private let commandRunner: CommandRunner + + public init(commandRunner: CommandRunner = CommandRunner()) { + self.commandRunner = commandRunner + } + + public func readLaunchAgents() async -> Set { + let paths = [ + "\(NSHomeDirectory())/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", + ] + var urls = Set() + for path in paths { + let dir = URL(fileURLWithPath: path) + guard let contents = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil + ) else { continue } + for url in contents where url.pathExtension == "plist" { + urls.insert(url) + } + } + return urls + } + + public func readLoginItems() async -> Set { + var urls = Set() + let result = try? await commandRunner.run( + command: "/usr/bin/osascript", + arguments: ["-e", "tell application \"System Events\" to get the path of every login item"] + ) + if let output = result?.stdout { + for line in output.components(separatedBy: ",") where !line.isEmpty { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + urls.insert(URL(fileURLWithPath: trimmed)) + } + } + } + return urls + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/BackgroundItemsCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/BackgroundItemsCache.swift new file mode 100644 index 0000000..ce215e7 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/BackgroundItemsCache.swift @@ -0,0 +1,20 @@ +import Foundation + +public actor BackgroundItemsCache { + private var launchAgents: Set = [] + private var loginItems: Set = [] + + public init() {} + + public func getLaunchAgents() -> Set { launchAgents } + public func getLoginItems() -> Set { loginItems } + + public func setLaunchAgents(_ urls: Set) { launchAgents = urls } + public func setLoginItems(_ urls: Set) { loginItems = urls } + + public func warmup() async { + let reader = BackgroundItemsReader() + launchAgents = await reader.readLaunchAgents() + loginItems = await reader.readLoginItems() + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/CodesignCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/CodesignCache.swift new file mode 100644 index 0000000..42021dd --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/CodesignCache.swift @@ -0,0 +1,27 @@ +import Foundation + +public actor CodesignCache { + private var cache: [String: String] = [:] + + public init() {} + + public func getTeamID(url: URL, commandRunner: any CommandRunning) async -> String? { + let key = url.standardizedFileURL.path + if let cached = cache[key] { return cached.isEmpty ? nil : cached } + + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-dv", "--verbose=4", key]) + guard let output = result?.stderr else { + cache[key] = "" + return nil + } + if let range = output.range(of: "TeamIdentifier=") { + let start = range.upperBound + let end = output[start...].firstIndex(where: { $0.isWhitespace || $0.isNewline }) ?? output.endIndex + let teamID = String(output[start.. AppIdentity? { + cache[bundleID] + } + + public func set(bundleID: String, identity: AppIdentity) { + cache[bundleID] = identity + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/LSRegisterCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/LSRegisterCache.swift new file mode 100644 index 0000000..04da7d0 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/LSRegisterCache.swift @@ -0,0 +1,90 @@ +import Foundation +import OSLog + +private extension Logger { + static let lsCache = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "LSRegisterCache") +} + +public actor LSRegisterCache { + private struct Entry: Codable { + let bundleID: String + let url: Data + let timestamp: Date + } + + private var cache: [String: Entry] = [:] + private let ttl: TimeInterval = 86400 + private let storageURL: URL + + public init() { + let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + self.storageURL = cachesDir.appendingPathComponent("com.macos-cleaner/lsregister.json") + Task { await load() } + } + + public func get(bundleID: String) -> URL? { + guard let entry = cache[bundleID] else { return nil } + guard Date().timeIntervalSince(entry.timestamp) < ttl else { + cache[bundleID] = nil + return nil + } + do { + let bookmarkData = entry.url + var isStale = false + let url = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale) + if isStale { + cache[bundleID] = nil + return nil + } + return url + } catch { + cache[bundleID] = nil + return nil + } + } + + public func set(bundleID: String, url: URL) { + guard let bookmarkData = try? url.bookmarkData() else { return } + cache[bundleID] = Entry(bundleID: bundleID, url: bookmarkData, timestamp: Date()) + Task { await save() } + } + + public func warmup() async { + guard cache.isEmpty else { return } + Logger.lsCache.info("Warming up LSRegisterCache") + let appDirs = [ + URL(fileURLWithPath: "/Applications"), + URL(fileURLWithPath: "\(NSHomeDirectory())/Applications"), + ] + var entries: [String: Entry] = [:] + for dir in appDirs { + guard let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { continue } + for url in contents where url.pathExtension == "app" { + if let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier { + if let bookmarkData = try? url.bookmarkData() { + entries[bundleID] = Entry(bundleID: bundleID, url: bookmarkData, timestamp: Date()) + } + } + } + } + cache = entries + Logger.lsCache.info("LSRegisterCache warmed with \(entries.count) entries") + await save() + } + + private func save() async { + let entries = Array(cache.values) + guard let data = try? JSONEncoder().encode(entries) else { return } + try? FileManager.default.createDirectory(at: storageURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? data.write(to: storageURL, options: .atomic) + } + + private func load() { + guard let data = try? Data(contentsOf: storageURL) else { return } + guard let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return } + for entry in entries { + cache[entry.bundleID] = entry + } + Logger.lsCache.info("Loaded \(entries.count) entries from LSRegisterCache on disk") + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/LaunchctlCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/LaunchctlCache.swift new file mode 100644 index 0000000..5154bb4 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/LaunchctlCache.swift @@ -0,0 +1,23 @@ +import Foundation + +public actor LaunchctlCache { + private var cache: [String: String] = [:] + + public init() {} + + public func getPlistContent(for url: URL) async -> String? { + let key = url.standardizedFileURL.path + if let cached = cache[key] { return cached.isEmpty ? nil : cached } + do { + let data = try Data(contentsOf: url) + let content = String(decoding: data, as: UTF8.self) + cache[key] = content + return content + } catch { + cache[key] = "" + return nil + } + } + + public func clear() { cache.removeAll() } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/MdfindCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/MdfindCache.swift new file mode 100644 index 0000000..eb71964 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/MdfindCache.swift @@ -0,0 +1,15 @@ +import Foundation + +public actor MdfindCache { + private var cache: [String: Set] = [:] + + public init() {} + + public func get(query: String) -> Set? { + cache[query] + } + + public func set(query: String, results: Set) { + cache[query] = results + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/PlistContentCache.swift b/MacOSCleaner/Features/Uninstaller/Caches/PlistContentCache.swift new file mode 100644 index 0000000..9e77bd1 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/PlistContentCache.swift @@ -0,0 +1,26 @@ +import Foundation + +public actor PlistContentCache { + private var cache: [String: String] = [:] + + public init() {} + + public func getContent(url: URL) async -> String? { + let key = url.standardizedFileURL.path + if let cached = cache[key] { return cached } + + do { + let data = try Data(contentsOf: url) + var format = PropertyListSerialization.PropertyListFormat.xml + let plist = try PropertyListSerialization.propertyList(from: data, format: &format) + let serialized = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + if let str = String(data: serialized, encoding: .utf8) { + cache[key] = str + return str + } + } catch { + cache[key] = "" + } + return nil + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Caches/ProbeCaches.swift b/MacOSCleaner/Features/Uninstaller/Caches/ProbeCaches.swift new file mode 100644 index 0000000..4d6b6cc --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Caches/ProbeCaches.swift @@ -0,0 +1,26 @@ +import Foundation + +public actor ProbeCaches { + public let codesign: CodesignCache + public let plist: PlistContentCache + public let identity: IdentityCache + public let mdfind: MdfindCache + public let launchctl: LaunchctlCache + public let backgroundItems: BackgroundItemsCache + + public init( + codesign: CodesignCache = CodesignCache(), + plist: PlistContentCache = PlistContentCache(), + identity: IdentityCache = IdentityCache(), + mdfind: MdfindCache = MdfindCache(), + launchctl: LaunchctlCache = LaunchctlCache(), + backgroundItems: BackgroundItemsCache = BackgroundItemsCache() + ) { + self.codesign = codesign + self.plist = plist + self.identity = identity + self.mdfind = mdfind + self.launchctl = launchctl + self.backgroundItems = backgroundItems + } +} diff --git a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift new file mode 100644 index 0000000..da4d42a --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift @@ -0,0 +1,269 @@ +import Foundation + +public actor CandidateCollector { + private let fileManager: FileManager + private let commandRunner: any CommandRunning + + public init(fileManager: FileManager = .default, commandRunner: any CommandRunning = CommandRunner()) { + self.fileManager = fileManager + self.commandRunner = commandRunner + } + + public func collect(identity: AppIdentity) async -> Set { + var candidates = Set() + let home = NSHomeDirectory() + + // 1. Fixed popular paths + let basePaths = [ + "\(home)/Library/Application Support", + "\(home)/Library/Caches", + "\(home)/Library/Containers", + "\(home)/Library/Group Containers", + "\(home)/Library/Preferences", + "\(home)/Library/Preferences/ByHost", + "\(home)/Library/HTTPStorages", + "\(home)/Library/WebKit", + "\(home)/Library/Saved Application State", + "\(home)/Library/Application Scripts", + "\(home)/Library/Logs", + "\(home)/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", + "/Library/Preferences", + "/Library/Application Support", + "\(home)/Library/Developer", + ] + + for base in basePaths { + let url = URL(fileURLWithPath: base) + candidates.formUnion(await shallowScan(url, identity: identity)) + } + + // 2. Deep scan critical folders + let deepFolders = [ + "\(home)/Library/Application Support", + "\(home)/Library/Caches", + "\(home)/Library/Containers", + "\(home)/Library/Group Containers", + "\(home)/Library/HTTPStorages", + "\(home)/Library/WebKit", + "\(home)/Library/Preferences", + "\(home)/Library/Application Scripts", + ] + for dir in deepFolders { + let url = URL(fileURLWithPath: dir) + candidates.formUnion(await deepScan(url, identity: identity, depth: 0, maxDepth: 5)) + } + + // 3. pkgutil receipts + if identity.bundleID.count > 0 { + if let result = try? await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--files", identity.bundleID]) { + for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { + let path = "/\(line)" + if fileManager.fileExists(atPath: path) { + candidates.insert(URL(fileURLWithPath: path)) + } + } + } + } + + // 4. mdfind + let mdfindCandidates = await runMdfind(identity: identity) + candidates.formUnion(mdfindCandidates) + + // 5. App-specific Electron paths + if identity.isElectron { + let electronPath = "\(home)/Library/Application Support/\(identity.appName)" + if fileManager.fileExists(atPath: electronPath) { + candidates.insert(URL(fileURLWithPath: electronPath)) + } + } + + // 6. JetBrains-specific + if identity.isJetBrains { + let jbPath = "\(home)/Library/Application Support/JetBrains" + if fileManager.fileExists(atPath: jbPath) { + candidates.formUnion(await shallowScan(URL(fileURLWithPath: jbPath), identity: identity)) + candidates.formUnion(await deepScan(URL(fileURLWithPath: jbPath), identity: identity, depth: 0, maxDepth: 4)) + } + } + + // 7. Docker-specific + if identity.isDocker { + let dockerPaths = [ + "\(home)/Library/Containers/com.docker.docker", + "\(home)/Library/Group Containers/group.com.docker", + ] + for p in dockerPaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 8. Adobe-specific + let adobeVendor = identity.appName.lowercased().hasPrefix("adobe") || + identity.bundleID.lowercased().hasPrefix("com.adobe.") + if adobeVendor { + let adobePaths = [ + "\(home)/Library/Application Support/Adobe", + "/Library/Application Support/Adobe", + "\(home)/Library/Preferences/Adobe", + "/Library/Preferences/Adobe", + "\(home)/.adobe", + "\(home)/Creative Cloud Files", + ] + for p in adobePaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 9. Microsoft Office-specific + let msVendor = identity.appName.lowercased().hasPrefix("microsoft") || + identity.bundleID.lowercased().hasPrefix("com.microsoft.") + if msVendor { + let msPaths = [ + "\(home)/Library/Application Support/Microsoft", + "\(home)/Library/Application Support/Microsoft Office", + "\(home)/Library/Group Containers/UBF8T346G9.Office", + "\(home)/Library/Group Containers/UBF8T346G9.OneDriveStandaloneSuite", + "/Library/Application Support/Microsoft", + "\(home)/Library/Containers/com.microsoft.word", + "\(home)/Library/Containers/com.microsoft.excel", + "\(home)/Library/Containers/com.microsoft.powerpoint", + "\(home)/Library/Containers/com.microsoft.outlook", + "\(home)/Library/Containers/com.microsoft.teams", + ] + for p in msPaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 10. Steam-specific + if identity.bundleID == "com.valvesoftware.steam" || identity.appName == "Steam" { + let steamPaths = [ + "\(home)/Library/Application Support/Steam", + ] + for p in steamPaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 11. Epic Games-specific + if identity.bundleID == "com.epicgames.EpicGamesLauncher" || identity.appName.lowercased().contains("epic") { + let epicPaths = [ + "\(home)/Library/Application Support/Epic", + "\(home)/Library/Application Support/Epic Games Launcher", + ] + for p in epicPaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 12. Unity-specific + if identity.bundleID.lowercased().hasPrefix("com.unity3d.") || identity.appName == "Unity Hub" { + let unityPaths = [ + "\(home)/Library/Application Support/Unity", + "\(home)/Library/Application Support/Unity Hub", + "\(home)/.local/share/unity3d", + ] + for p in unityPaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + // 13. Network extension / VPN-specific + let isNetworkExt = identity.bundleID.lowercased().contains("littlesnitch") || + identity.bundleID.lowercased().contains("nordvpn") || + identity.bundleID.lowercased().contains("expressvpn") || + identity.appName.lowercased().contains("vpn") || + identity.appName.lowercased().contains("snitch") + if isNetworkExt { + let nePaths = [ + "/Library/SystemExtensions", + "/Library/StagedExtensions", + "\(home)/Library/Application Support/Little Snitch", + "\(home)/Library/Application Support/NordVPN", + ] + for p in nePaths where fileManager.fileExists(atPath: p) { + candidates.insert(URL(fileURLWithPath: p)) + } + } + + return candidates + } + + private func shallowScan(_ url: URL, identity: AppIdentity) async -> Set { + var found = Set() + guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return found + } + for item in contents { + if matchCandidate(item, identity: identity) { + found.insert(item) + } + } + return found + } + + private func deepScan(_ url: URL, identity: AppIdentity, depth: Int, maxDepth: Int) async -> Set { + guard depth <= maxDepth else { return [] } + var found = Set() + guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return found + } + for item in contents { + if matchCandidate(item, identity: identity) { + found.insert(item) + } + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue { + let sub = await deepScan(item, identity: identity, depth: depth + 1, maxDepth: maxDepth) + found.formUnion(sub) + } + } + return found + } + + private func matchCandidate(_ url: URL, identity: AppIdentity) -> Bool { + let name = url.lastPathComponent + let lowerName = name.lowercased() + + if name == identity.bundleID || name.hasPrefix(identity.bundleID + ".") { + return true + } + if name == identity.appName || name.hasPrefix(identity.appName + " ") || name.hasPrefix(identity.appName + ".") { + return true + } + if identity.vendorNames.contains(name) { + return true + } + if identity.vendorNames.contains(where: { lowerName.contains($0.lowercased()) }) { + return true + } + if lowerName.contains(identity.bundleID.lowercased()) { + return true + } + if lowerName.contains(identity.appName.lowercased()) { + return true + } + if lowerName == identity.executableName.lowercased() { + return true + } + + return false + } + + private func runMdfind(identity: AppIdentity) async -> Set { + var urls = Set() + let queries = [identity.bundleID, identity.appName, identity.executableName] + for query in queries { + guard !query.isEmpty else { continue } + if let result = try? await commandRunner.run(command: "/usr/bin/mdfind", arguments: [query]) { + for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { + let url = URL(fileURLWithPath: line) + urls.insert(url) + } + } + } + return urls + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Confidence.swift b/MacOSCleaner/Features/Uninstaller/Confidence.swift new file mode 100644 index 0000000..1d537af --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Confidence.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct ConfidenceAssessment: Sendable, Hashable { + public let evidence: Set + public let score: Int + public let tier: ConfidenceTier + public let missingCritical: [Evidence] +} + +public enum ConfidenceTier: String, Sendable, Hashable, CaseIterable, Comparable { + case ignore + case possible + case veryLikely + case guaranteed + + public var displayKey: String { + switch self { + case .ignore: return "uninstaller.tier.ignore" + case .possible: return "uninstaller.tier.possible" + case .veryLikely: return "uninstaller.tier.very_likely" + case .guaranteed: return "uninstaller.tier.guaranteed" + } + } + + public static func < (lhs: ConfidenceTier, rhs: ConfidenceTier) -> Bool { + let order: [ConfidenceTier] = [.ignore, .possible, .veryLikely, .guaranteed] + guard let l = order.firstIndex(of: lhs), let r = order.firstIndex(of: rhs) else { return false } + return l < r + } +} diff --git a/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift new file mode 100644 index 0000000..ca57bf7 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum ConfidenceEngine { + public static let criticalEvidence: Set = [ + .bundleIDExact, .bundleIDPrefix, .packageReceipt, + .spotlightBundleAttr, .loginItem, + ] + + public static func assess(_ evidence: Set, identity: AppIdentity, weights: ScoringWeights = .default) -> ConfidenceAssessment { + let score = weights.score(evidence) + let missing = criticalEvidence.subtracting(evidence) + + var tier: ConfidenceTier + if score >= 100 { + if evidence.contains(.bundleIDExact) || evidence.contains(.bundleIDPrefix) + || evidence.contains(.packageReceipt) || evidence.contains(.spotlightBundleAttr) + || evidence.contains(.loginItem) + || (evidence.contains(.teamID) && (evidence.contains(.launchAgent) || evidence.contains(.launchDaemon) + || evidence.contains(.extension) || evidence.contains(.appGroup) || evidence.contains(.container))) { + tier = .guaranteed + } else { + tier = .veryLikely + } + } else if score >= 60 { + tier = .veryLikely + } else if score >= 30 { + tier = .possible + } else { + tier = .ignore + } + + if identity.isJetBrains && evidence.contains(.vendorName) && !evidence.contains(.appNameExact) && !evidence.contains(.bundleIDPrefix) { + let nonVendorCount = evidence.filter { $0 != .vendorName && $0 != .parentDirectory }.count + if nonVendorCount < 3 { + if tier == .guaranteed { tier = .veryLikely } + else if tier == .veryLikely { tier = .possible } + else { tier = .ignore } + } + } + + return ConfidenceAssessment(evidence: evidence, score: score, tier: tier, missingCritical: Array(missing)) + } + + public static func assessAll(_ nodes: [EvidenceGraphNode], identity: AppIdentity, weights: ScoringWeights = .default) -> [ConfidenceAssessment] { + nodes.map { assess($0.evidence, identity: identity, weights: weights) } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift new file mode 100644 index 0000000..ae93ff3 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift @@ -0,0 +1,113 @@ +import Foundation + +public enum DeveloperComponentsDetector { + public static func detect( + appName: String, + bundleID: String?, + fileManager: FileManager = .default + ) async -> [UninstallerService.RelatedCleanupComponent] { + let fm = fileManager + let home = NSHomeDirectory() + var components: [UninstallerService.RelatedCleanupComponent] = [] + let lowerName = appName.lowercased() + let lowerID = bundleID?.lowercased() ?? "" + + if lowerName.contains("android studio") || lowerID.contains("android.studio") { + let sdkURL = URL(fileURLWithPath: "\(home)/Library/Android/sdk") + if fm.fileExists(atPath: sdkURL.path) { + let sdkSize = getDirectorySize(url: sdkURL) + if sdkSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.android_sdk".localized, + category: .androidSDK, sizeBytes: sdkSize, + url: sdkURL + )) + } + } + let gradleURL = URL(fileURLWithPath: "\(home)/.gradle") + if fm.fileExists(atPath: gradleURL.path) { + let gradleSize = getDirectorySize(url: gradleURL) + if gradleSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.gradle_cache".localized, + category: .gradleMaven, sizeBytes: gradleSize, + url: gradleURL + )) + } + } + } + + if lowerID == "com.apple.dt.xcode" || (lowerName == "xcode" && lowerID.hasPrefix("com.apple.dt")) { + let derivedURL = URL(fileURLWithPath: "\(home)/Library/Developer/Xcode/DerivedData") + if fm.fileExists(atPath: derivedURL.path) { + let derivedSize = getDirectorySize(url: derivedURL) + if derivedSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.xcode_derived_data".localized, + category: .xcode, sizeBytes: derivedSize, + url: derivedURL + )) + } + } + let simURL = URL(fileURLWithPath: "\(home)/Library/Developer/CoreSimulator") + if fm.fileExists(atPath: simURL.path) { + let simSize = getDirectorySize(url: simURL) + if simSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.ios_simulators".localized, + category: .iosSimulators, sizeBytes: simSize, + url: simURL + )) + } + } + } + + if lowerName == "flutter" || lowerID.contains("flutter") { + let flutterURL = URL(fileURLWithPath: "\(home)/.pub-cache") + if fm.fileExists(atPath: flutterURL.path) { + let flutterSize = getDirectorySize(url: flutterURL) + if flutterSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.flutter_cache".localized, + category: .flutterDart, sizeBytes: flutterSize, + url: flutterURL + )) + } + } + } + + if lowerName.contains("orbstack") || lowerID == "dev.orbstack" || lowerName.contains("docker") || lowerID == "com.docker.docker" { + let dockerURL = URL(fileURLWithPath: "\(home)/Library/Containers/com.docker.docker") + if fm.fileExists(atPath: dockerURL.path) { + let dockerSize = getDirectorySize(url: dockerURL) + if dockerSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.docker".localized, + category: .docker, sizeBytes: dockerSize, + url: dockerURL + )) + } + } + } + + if lowerName == "homebrew" || lowerID == "com.homebrew" { + let brewURL = URL(fileURLWithPath: "/opt/homebrew") + if fm.fileExists(atPath: brewURL.path) { + let brewSize = getDirectorySize(url: brewURL) + if brewSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.homebrew".localized, + category: .packageManagers, sizeBytes: brewSize, + url: brewURL + )) + } + } + } + + return components + } + + private static func getDirectorySize(url: URL) -> Int64 { + FileManager.default.getDirectorySize(url: url, excludedPaths: []) + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Evidence.swift b/MacOSCleaner/Features/Uninstaller/Evidence.swift new file mode 100644 index 0000000..0b770f2 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Evidence.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum Evidence: String, Sendable, Hashable, CaseIterable { + case bundleIDExact + case bundleIDPrefix + case appNameExact + case appNamePrefix + case executableName + case frameworkName + case xpcServiceName + case plugInName + case vendorName + + case teamID + case developerSignature + + case launchAgent + case launchDaemon + case loginItem + case appGroup + case container + case `extension` + case xpcConnection + + case packageReceipt + case plistContent + + case spotlight + case spotlightBundleAttr + case spotlightCreator + + case fileContent + case electronCache + case jetBrainsConfig + case flutterBuild + + case parentDirectory + + case launchServicesRegistered +} + +public enum EvidenceCategory: String, Sendable, Hashable, CaseIterable { + case identity + case signature + case system + case metadata + case content + case graph + case launchServices +} + +public extension Evidence { + var category: EvidenceCategory { + switch self { + case .bundleIDExact, .bundleIDPrefix, .appNameExact, .appNamePrefix, + .executableName, .frameworkName, .xpcServiceName, + .plugInName, .vendorName: + return .identity + case .teamID, .developerSignature: + return .signature + case .launchAgent, .launchDaemon, .loginItem, + .appGroup, .container, .extension, .xpcConnection: + return .system + case .packageReceipt, .plistContent: + return .metadata + case .spotlight, .spotlightBundleAttr, .spotlightCreator: + return .content + case .fileContent, .electronCache, .jetBrainsConfig, .flutterBuild: + return .content + case .parentDirectory: + return .graph + case .launchServicesRegistered: + return .launchServices + } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift b/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift new file mode 100644 index 0000000..325db54 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift @@ -0,0 +1,64 @@ +import Foundation + +public struct ExplanationContext: Sendable { + public let bundleID: String? + public let appName: String + public let teamID: String? + + public init(bundleID: String?, appName: String, teamID: String?) { + self.bundleID = bundleID + self.appName = appName + self.teamID = teamID + } +} + +public struct EvidenceExplanation: Sendable, Hashable { + public let evidence: Evidence + public let title: String + public let description: String + public let category: EvidenceCategory + + public init(evidence: Evidence, title: String, description: String) { + self.evidence = evidence + self.title = title + self.description = description + self.category = evidence.category + } +} + +public enum EvidenceExplanations { + public static func explanation(for evidence: Evidence, args: CVarArg...) -> EvidenceExplanation { + let keyPrefix = "uninstaller.evidence.\(evidence.rawValue)" + let title = "\(keyPrefix).title".localized + let description = String(format: "\(keyPrefix).description".localized, arguments: args) + return EvidenceExplanation(evidence: evidence, title: title, description: description) + } + + public static func explanations(for set: Set, context: ExplanationContext? = nil) -> [EvidenceCategory: [EvidenceExplanation]] { + var args: [Evidence: CVarArg] = [:] + if let ctx = context { + for e in set { + switch e { + case .bundleIDPrefix, .spotlightBundleAttr: + args[e] = ctx.bundleID ?? ctx.appName + case .teamID: + args[e] = ctx.teamID ?? "" + case .appNameExact, .appNamePrefix, .executableName, .vendorName: + args[e] = ctx.appName + default: + break + } + } + } + var grouped: [EvidenceCategory: [EvidenceExplanation]] = [:] + for evidence in set { + let arg = args[evidence] ?? "" as CVarArg + let exp = explanation(for: evidence, args: arg) + grouped[exp.category, default: []].append(exp) + } + for key in grouped.keys { + grouped[key]?.sort { $0.evidence.rawValue < $1.evidence.rawValue } + } + return grouped + } +} diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift b/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift new file mode 100644 index 0000000..309c6d0 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift @@ -0,0 +1,99 @@ +import Foundation + +public struct EvidenceGraphNode: Sendable, Hashable { + public let url: URL + public var evidence: Set + public var parents: Set + public var children: Set + + public init(url: URL, evidence: Set = [], parents: Set = [], children: Set = []) { + self.url = url + self.evidence = evidence + self.parents = parents + self.children = children + } + + public func hash(into hasher: inout Hasher) { hasher.combine(url) } + public static func == (lhs: EvidenceGraphNode, rhs: EvidenceGraphNode) -> Bool { lhs.url == rhs.url } +} + +public actor EvidenceGraph { + private var nodes: [URL: EvidenceGraphNode] = [:] + private let identity: AppIdentity + + public static let boundaryRoots: Set = [ + "Application Support", "Caches", "Containers", "Group Containers", + "WebKit", "HTTPStorages", "Application Scripts", + "Preferences", "ByHost", "Saved Application State", + ] + + public init(identity: AppIdentity) { + self.identity = identity + let seed = EvidenceGraphNode(url: identity.bundleURL, evidence: [.bundleIDExact]) + nodes[identity.bundleURL] = seed + } + + public func record(_ evidence: Evidence, for url: URL) { + var node = nodes[url] ?? EvidenceGraphNode(url: url) + node.evidence.insert(evidence) + nodes[url] = node + } + + public func record(_ evidences: Set, for url: URL) { + var node = nodes[url] ?? EvidenceGraphNode(url: url) + node.evidence.formUnion(evidences) + nodes[url] = node + } + + public func attach(_ url: URL, to parent: URL, via: Evidence) { + var child = nodes[url] ?? EvidenceGraphNode(url: url) + child.parents.insert(parent) + nodes[url] = child + + var parentNode = nodes[parent] ?? EvidenceGraphNode(url: parent) + parentNode.children.insert(url) + nodes[parent] = parentNode + + child.evidence.insert(via) + nodes[url] = child + } + + public func node(for url: URL) -> EvidenceGraphNode? { + nodes[url] + } + + public func allNodes() -> [EvidenceGraphNode] { + Array(nodes.values) + } + + public func allURLs() -> Set { + Set(nodes.keys) + } + + public func propagateFromSeeds(maxDepth: Int = 5) { + let seeds = nodes.filter { $0.value.evidence.contains(.bundleIDExact) } + for seed in seeds.values { + propagate(from: seed.url, depth: 0, maxDepth: maxDepth) + } + } + + private func propagate(from url: URL, depth: Int, maxDepth: Int) { + guard depth < maxDepth else { return } + guard let node = nodes[url] else { return } + + let isBoundary = EvidenceGraph.boundaryRoots.contains(url.lastPathComponent) + if depth > 0 && isBoundary { return } + + for child in node.children { + if var childNode = nodes[child] { + if !childNode.evidence.contains(.parentDirectory) { + childNode.evidence.insert(.parentDirectory) + nodes[child] = childNode + } + propagate(from: child, depth: depth + 1, maxDepth: maxDepth) + } + } + } + + public func count() -> Int { nodes.count } +} diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift new file mode 100644 index 0000000..4c2fc96 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift @@ -0,0 +1,129 @@ +import Foundation + +public actor EvidenceProbe { + private let commandRunner: any CommandRunning + private let codesignCache: CodesignCache + private let plistCache: PlistContentCache + + public init( + commandRunner: any CommandRunning = CommandRunner(), + codesignCache: CodesignCache = CodesignCache(), + plistCache: PlistContentCache = PlistContentCache() + ) { + self.commandRunner = commandRunner + self.codesignCache = codesignCache + self.plistCache = plistCache + } + + public func probe(url: URL, identity: AppIdentity) async -> Set { + var evidence = Set() + + let fileName = url.lastPathComponent + let parentFolder = url.deletingLastPathComponent().lastPathComponent + let path = url.path.lowercased() + + // Identity checks + if fileName == identity.bundleID { + evidence.insert(.bundleIDExact) + } else if fileName.hasPrefix(identity.bundleID + ".") || fileName == identity.bundleID { + evidence.insert(.bundleIDPrefix) + } + + if fileName == identity.appName { + evidence.insert(.appNameExact) + } else if fileName.hasPrefix(identity.appName + " ") || fileName.hasPrefix(identity.appName + "-") { + evidence.insert(.appNamePrefix) + } + + if identity.vendorNames.contains(fileName) || identity.vendorNames.contains(parentFolder) { + evidence.insert(.vendorName) + } + + if identity.frameworkNames.contains(fileName) || identity.frameworkNames.contains(fileName.replacingOccurrences(of: ".framework", with: "")) { + evidence.insert(.frameworkName) + } + + if identity.xpcServiceNames.contains(fileName) || identity.xpcServiceNames.contains(fileName.replacingOccurrences(of: ".xpc", with: "")) { + evidence.insert(.xpcServiceName) + } + + if identity.plugInNames.contains(fileName) || identity.plugInNames.contains(fileName.replacingOccurrences(of: ".bundle", with: "")) { + evidence.insert(.plugInName) + } + + if fileName == identity.executableName { + evidence.insert(.executableName) + } + + // Container checks + if parentFolder == "Containers" && fileName == identity.bundleID { + evidence.insert(.container) + } + if parentFolder == "Group Containers" && (fileName == "group.\(identity.bundleID)" || fileName.hasPrefix("group.\(identity.bundleID).")) { + evidence.insert(.appGroup) + } + + // System integration + if path.contains("/launchagents/") && (path.contains(identity.bundleID.lowercased()) || fileName.lowercased().contains(identity.appName.lowercased())) { + evidence.insert(.launchAgent) + } + if path.contains("/launchdaemons/") && (path.contains(identity.bundleID.lowercased()) || fileName.lowercased().contains(identity.appName.lowercased())) { + evidence.insert(.launchDaemon) + } + + // Application Scripts + if path.contains("/application scripts/\(identity.bundleID.lowercased())") { + evidence.insert(.extension) + } + + // Electron cache detection + if identity.isElectron { + let electronDirs = ["Cache", "Code Cache", "GPUCache", "Local Storage", "Session Storage", "IndexedDB", "Network"] + if electronDirs.contains(fileName) && path.contains("/application support/\(identity.appName.lowercased())") { + evidence.insert(.electronCache) + } + } + + // JetBrains config + if identity.isJetBrains && path.contains("/application support/jetbrains") { + evidence.insert(.jetBrainsConfig) + } + + // Flutter build + if identity.isFlutter && (path.contains(".dart_tool") || path.contains("/build/flutter")) { + evidence.insert(.flutterBuild) + } + + // Code signature probe + let binaryExts: Set = ["", "app", "bundle", "kext", "framework", "dylib", "xpc"] + let fileExt = url.pathExtension + if binaryExts.contains(fileExt) || fileExt.isEmpty { + if let candidateTeamID = await codesignCache.getTeamID(url: url, commandRunner: commandRunner) { + if candidateTeamID == identity.teamID && identity.teamID != nil { + evidence.insert(.teamID) + } + } + if let auth = identity.signingAuthority { + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-dv", "--verbose=4", url.path]) + if let output = result?.stderr, output.contains(auth) { + evidence.insert(.developerSignature) + } + } + } + + // Plist content probe + if url.pathExtension == "plist" || fileName.hasSuffix(".plist") { + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + if size > 0 && size < 512 * 1024 { + if let content = await plistCache.getContent(url: url) { + let lower = content.lowercased() + if lower.contains(identity.bundleID.lowercased()) || lower.contains(identity.appName.lowercased()) { + evidence.insert(.plistContent) + } + } + } + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift b/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift new file mode 100644 index 0000000..f5bbd4f --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift @@ -0,0 +1,136 @@ +import Foundation + +// MARK: — EvidenceSource (coarser categories for scoring + negative) + +public enum EvidenceSource: Hashable, Sendable { + case rule + case bundleID + case teamID + case appName + case plistContent + case spotlight + case parentFolder + case developerSignature + case foreignBundleID + case foreignTeamID + case systemArtifact +} + +// MARK: — ArtifactEvidence (explainable evidence point) + +public struct ArtifactEvidence: Hashable, Sendable { + public let source: EvidenceSource + public let weight: Int + + public init(source: EvidenceSource, weight: Int) { + self.source = source + self.weight = weight + } +} + +// MARK: — ScoredArtifact (output of scoring pipeline) + +public struct ScoredArtifact: Hashable, Sendable { + public let url: URL + public let score: Int + public let evidence: [ArtifactEvidence] + + public init(url: URL, score: Int, evidence: [ArtifactEvidence]) { + self.url = url + self.score = score + self.evidence = evidence + } +} + +// MARK: — ScoreThresholds (configurable, not hardcoded) + +public struct ScoreThresholds: Sendable, Equatable { + public var guaranteed: Int + public var veryLikely: Int + public var possible: Int + + public static let `default` = ScoreThresholds( + guaranteed: 100, + veryLikely: 60, + possible: 30 + ) + + public init(guaranteed: Int, veryLikely: Int, possible: Int) { + self.guaranteed = guaranteed + self.veryLikely = veryLikely + self.possible = possible + } +} + +// MARK: — Bridge from Evidence → EvidenceSource + +extension Evidence { + public var source: EvidenceSource { + switch self { + case .bundleIDExact, .bundleIDPrefix: + return .bundleID + case .appNameExact, .appNamePrefix: + return .appName + case .teamID: + return .teamID + case .developerSignature: + return .developerSignature + case .parentDirectory: + return .parentFolder + case .plistContent: + return .plistContent + case .spotlight, .spotlightBundleAttr, .spotlightCreator: + return .spotlight + case .vendorName, .frameworkName, .xpcServiceName, .plugInName, + .executableName, .fileContent, + .electronCache, .jetBrainsConfig, .flutterBuild, + .launchAgent, .launchDaemon, .loginItem, + .appGroup, .container, .extension, .xpcConnection, + .packageReceipt, .launchServicesRegistered: + return .rule + } + } +} + +// MARK: — Build ArtifactEvidence from a set of Evidence + +extension Sequence where Element == Evidence { + public func artifactEvidence(weights: ScoringWeights = .default) -> [ArtifactEvidence] { + var seen = Set() + var result: [ArtifactEvidence] = [] + for e in self { + let s = e.source + if !seen.contains(s) { + let w = weights.weight(for: s) + result.append(ArtifactEvidence(source: s, weight: w)) + seen.insert(s) + } + } + return result + } + + public func totalScore(weights: ScoringWeights = .default) -> Int { + artifactEvidence(weights: weights).reduce(0) { $0 + $1.weight } + } +} + +// MARK: — ScoringWeights adds source-based lookup + +extension ScoringWeights { + public func weight(for source: EvidenceSource) -> Int { + switch source { + case .bundleID: return bundleIDExact + case .teamID: return teamID + case .appName: return appNameExact + case .plistContent: return plistContent + case .spotlight: return self.spotlight + case .parentFolder: return parentDirectory + case .developerSignature: return self.developerSignature + case .rule: + return 0 + case .foreignBundleID: return -200 + case .foreignTeamID: return -150 + case .systemArtifact: return -100 + } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/ParentLinker.swift b/MacOSCleaner/Features/Uninstaller/ParentLinker.swift new file mode 100644 index 0000000..63e4bce --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/ParentLinker.swift @@ -0,0 +1,44 @@ +import Foundation + +public enum ParentLinker { + public static func link(url: URL, identity: AppIdentity) -> [(parent: URL, via: Evidence)] { + var links: [(URL, Evidence)] = [] + let path = url.standardizedFileURL.path + let bundlePath = identity.bundleURL.standardizedFileURL.path + let home = NSHomeDirectory() + + guard path.hasPrefix(home + "/Library") || path.hasPrefix("/Library") || path.hasPrefix("/private/var/folders") else { + return links + } + + let pathComponents = (path as NSString).pathComponents + + for i in 0.. [(url: URL, evidence: ArtifactEvidence)] { + var results: [(URL, ArtifactEvidence)] = [] + + for dir in Self.searchDirectories { + let url = URL(fileURLWithPath: dir) + guard fileManager.fileExists(atPath: url.path), + let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + else { continue } + + for item in contents where item.pathExtension == "plist" || item.lastPathComponent.hasSuffix(".plist") { + let size = (try? item.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + guard size > 0, size < 512 * 1024 else { continue } + + guard let content = await plistCache.getContent(url: item) else { continue } + let lower = content.lowercased() + + var foundSources: [EvidenceSource] = [] + + if lower.contains(identity.bundleID.lowercased()) { + foundSources.append(.plistContent) + } + if let name = identity.bundleName, lower.contains(name.lowercased()) { + foundSources.append(.plistContent) + } + if lower.contains(identity.appName.lowercased()) { + foundSources.append(.plistContent) + } + if let devName = identity.signingAuthority?.lowercased(), lower.contains(devName) { + foundSources.append(.plistContent) + } + + for source in foundSources { + let name = item.lastPathComponent + if !name.contains(identity.bundleID) && !name.contains(identity.appName) + && !identity.vendorNames.contains(where: { name.contains($0) }) { + if name.contains("com.apple.") { + results.append((item, ArtifactEvidence(source: .foreignBundleID, weight: -200))) + continue + } + } + results.append((item, ArtifactEvidence(source: source, weight: 80))) + } + } + } + + return results + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/AdobeRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/AdobeRule.swift new file mode 100644 index 0000000..80a27a1 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/AdobeRule.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct AdobeRule: ApplicationRule { + public let displayName = "Adobe" + public let supportedBundleIDs: Set = [ + "com.adobe.ccx.process", + "com.adobe.Photoshop", + "com.adobe.Illustrator", + "com.adobe.PremierePro", + "com.adobe.AfterEffects", + "com.adobe.InDesign", + "com.adobe.LightroomClassic", + "com.adobe.Lightroom", + "com.adobe.Acrobat.Pro", + "com.adobe.Acrobat.Reader", + "com.adobe.AfterEffects", + "com.audition", + "com.adobe.Animate", + "com.adobe.Dreamweaver", + "com.adobe.BrIDGE", + "com.adobe.indesign", + "com.adobe Prelude", + "com.adobe.MediaEncoder", + "com.adobe.spank", + ] + public let supportedTeamIDs: Set = [ + "JQ5W7278T3", + ] + public let supportedAppNames: Set = [ + "Adobe Creative Cloud", "Creative Cloud", + "Photoshop", "Adobe Photoshop", + "Illustrator", "Adobe Illustrator", + "Premiere Pro", "Adobe Premiere Pro", + "After Effects", "Adobe After Effects", + "InDesign", "Adobe InDesign", + "Lightroom Classic", "Adobe Lightroom Classic", + "Lightroom", "Adobe Lightroom", + "Acrobat", "Adobe Acrobat", + "Audition", "Adobe Audition", + "Animate", "Adobe Animate", + "Dreamweaver", "Adobe Dreamweaver", + "Bridge", "Adobe Bridge", + "Prelude", "Adobe Prelude", + "Media Encoder", "Adobe Media Encoder", + ] + + public init() {} + + public func matches(identity: AppIdentity) -> Bool { + if supportedBundleIDs.contains(identity.bundleID) { return true } + if let tid = identity.teamID, supportedTeamIDs.contains(tid) { return true } + if supportedAppNames.contains(identity.appName) { return true } + let lower = identity.appName.lowercased() + return lower.hasPrefix("adobe") || lower.contains("creative cloud") + } + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/adobe") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.adobe.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.adobe.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/caches/adobe") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + if path.contains("/logs/adobe") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/launchagents/com.adobe.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/launchdaemons/com.adobe.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/saved application state/com.adobe.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.hasSuffix("/.adobe") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.hasSuffix("/creative cloud files") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/application support/adobe/") { + let components = path.components(separatedBy: "/") + if let adobeIndex = components.firstIndex(of: "adobe"), + adobeIndex + 1 < components.count { + let sub = components[adobeIndex + 1] + if identity.appName.lowercased().contains(sub.lowercased()) || + sub.lowercased().contains(identity.appName.lowercased()) { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + } + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/AlfredRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/AlfredRule.swift new file mode 100644 index 0000000..4b20845 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/AlfredRule.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct AlfredRule: ApplicationRule { + public let displayName = "Alfred" + public let supportedBundleIDs: Set = ["com.runningwithcrayons.Alfred-Preferences"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Alfred", "Alfred 5"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/alfred") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.runningwithcrayons.alfred") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.runningwithcrayons.alfred-preferences.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/AndroidStudioRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/AndroidStudioRule.swift new file mode 100644 index 0000000..d5cb93c --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/AndroidStudioRule.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct AndroidStudioRule: ApplicationRule { + public let displayName = "Android Studio" + public let supportedBundleIDs: Set = [ + "com.google.android.studio", + ] + public let supportedTeamIDs: Set = [ + "EQHXZ8M8AV", + ] + public let supportedAppNames: Set = ["Android Studio"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/google/androidstudio") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/google/androidstudio") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + if path.contains("/logs/google/androidstudio") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/preferences/com.google.android.studio.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/android/sdk") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/.gradle") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/.android/avd") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/ApplicationRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/ApplicationRule.swift new file mode 100644 index 0000000..e5d092e --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/ApplicationRule.swift @@ -0,0 +1,20 @@ +import Foundation + +public protocol ApplicationRule: Sendable { + var displayName: String { get } + var supportedBundleIDs: Set { get } + var supportedTeamIDs: Set { get } + var supportedAppNames: Set { get } + func matches(identity: AppIdentity) -> Bool + func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] +} + +extension ApplicationRule { + public func matches(identity: AppIdentity) -> Bool { + if supportedBundleIDs.contains(identity.bundleID) { return true } + if let tid = identity.teamID, supportedTeamIDs.contains(tid) { return true } + if supportedAppNames.contains(identity.appName) { return true } + let lower = identity.appName.lowercased() + return supportedAppNames.contains { lower.contains($0.lowercased()) } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift new file mode 100644 index 0000000..1f767ea --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct BrowserRule: ApplicationRule { + public let displayName = "Browser" + public let supportedBundleIDs: Set = [ + "com.google.Chrome", + "com.google.Chrome.canary", + "com.chromium.Chromium", + "com.brave.Browser", + "com.microsoft.edgemac", + "com.microsoft.edgemac.canary", + "org.mozilla.firefox", + "org.mozilla.firefoxdeveloperedition", + "company.thebrowser.Browser", + ] + public let supportedTeamIDs: Set = [ + "EQHXZ8M8AV", // Google + "BFYZ25A2P4", // Brave + ] + public let supportedAppNames: Set = [ + "Google Chrome", "Chrome", "Chromium", "Brave Browser", "Brave", + "Microsoft Edge", "Edge", "Firefox", "Firefox Developer Edition", + "Arc", + ] + + private let browserArtifactDirs: Set = [ + "Default", "Profile", "Profiles", + "IndexedDB", "GPUCache", "Code Cache", + "Service Worker", "CacheStorage", + "Session Storage", "Local Extension Storage", + "blob_storage", "File System", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + let name = candidate.lastPathComponent + var evidence: [ArtifactEvidence] = [] + + if browserArtifactDirs.contains(name) { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + + if path.contains("/application support/\(identity.appName.lowercased())") { + evidence.append(ArtifactEvidence(source: .appName, weight: 60)) + } + + if path.contains("/caches/\(identity.appName.lowercased())") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/CloudStorageRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/CloudStorageRule.swift new file mode 100644 index 0000000..7f9c415 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/CloudStorageRule.swift @@ -0,0 +1,125 @@ +import Foundation + +public struct CloudStorageRule: ApplicationRule { + public let displayName = "Cloud Storage" + public let supportedBundleIDs: Set = [ + "com.getdropbox.dropbox", + "com.google.drivefs", + "com.microsoft.OneDrive", + "com.box.desktop", + "com.pcloud.pcloud.macos", + "mega.mac", + "com.nextcloud.desktopclient", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "Dropbox", "Google Drive", "OneDrive", "Box", + "pCloud", "MEGAsync", "Nextcloud", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/com.getdropbox.dropbox") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + if path.contains("/containers/com.dropbox.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/") && path.contains("com.getdropbox.dropbox") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application scripts/") && path.contains("com.getdropbox.dropbox") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/application support/dropbox") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/dropboxelectron") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.hasSuffix("/.dropbox") || path.contains("/.dropbox/") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.hasSuffix("/dropbox") || path.hasSuffix("/dropbox_debug.log") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/dropboxhelper tools") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/cloudstorage/dropbox") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + if path.contains("/application support/google/drive") || path.contains("/application support/google/drivefs") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.google.drivefs") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + if path.contains("/group containers/") && path.contains("group.com.google.drivefs") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application scripts/com.google.drivefs") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.google.drivefs") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/onedrive") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.microsoft.onedrive") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/ubf8t346g9.officeonedrivesyncintegration") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.hasSuffix("/onedrive") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + if path.contains("/application support/box") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.box.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/pcloud") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.pcloud.pcloud.macos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.hasSuffix("/pclouddrive") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + if path.contains("/application support/mega limited") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/mega.mac") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.hasSuffix("/mega") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + if path.contains("/application support/nextcloud") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.nextcloud.desktopclient") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.hasSuffix("/nextcloud") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/CommunicationRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/CommunicationRule.swift new file mode 100644 index 0000000..2a9a0e8 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/CommunicationRule.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct CommunicationRule: ApplicationRule { + public let displayName = "Communication" + public let supportedBundleIDs: Set = [ + "net.whatsapp.WhatsApp", + "ru.keepcoder.Telegram", + "org.signal.Signal", + "us.zoom.xos", + "com.skype.skype", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "WhatsApp", "Telegram", "Signal", "Zoom", "Skype", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/net.whatsapp.whatsapp") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application support/whatsapp") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/net.whatsapp.whatsapp") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/net.whatsapp.whatsapp.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/telegram") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/ru.keepcoder.telegram") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/containers/ru.keepcoder.telegram") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/") && path.contains("ru.keepcoder.telegram") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 85)) + } + if path.contains("/caches/ru.keepcoder.telegram") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/signal") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/org.signal.signal") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/org.signal.signal.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/zoom.us") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/zoomupdater") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/containers/us.zoom.xos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/caches/us.zoom.xos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/group containers/") && path.contains("zoomclient3rd") { + evidence.append(ArtifactEvidence(source: .rule, weight: 85)) + } + if path.contains("/.zoomus") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/desktop/zoom") || path.contains("/documents/zoom") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/privilegedhelpertools/us.zoom") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/audio/plug-ins/hal/zoomaudioddevice.driver") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/launchdaemons/") && (path.contains("us.zoom") || path.contains("com.zoom")) { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + + if path.contains("/application support/skype") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.skype.skype") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.skype.skype.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DaVinciResolveRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DaVinciResolveRule.swift new file mode 100644 index 0000000..5ccd729 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/DaVinciResolveRule.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct DaVinciResolveRule: ApplicationRule { + public let displayName = "DaVinci Resolve" + public let supportedBundleIDs: Set = ["com.blackmagic-design.DaVinciResolve"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["DaVinci Resolve"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/blackmagic design/davinci resolve") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/blackmagic design") && !path.contains("davinci") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.blackmagic-design.davinciresolve") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.blackmagic-design.davinciresolve.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/logs/davinciresolve") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/cacheclip") || path.contains("/resolve disk database") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DatabaseToolsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DatabaseToolsRule.swift new file mode 100644 index 0000000..af7ae3b --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/DatabaseToolsRule.swift @@ -0,0 +1,113 @@ +import Foundation + +public struct DatabaseToolsRule: ApplicationRule { + public let displayName = "Database Tools" + public let supportedBundleIDs: Set = [ + "com.tableplus.TablePlus", + "com.tinyapp.TablePlus", + "org.jkiss.dbeaver.core.product", + "com.sequel-ace.sequel-ace", + "com.oracle.mysql.workbench", + "org.pgadmin.pgadmin4", + "com.mongodb.compass", + "com.redis.RedisInsight", + "com.postgresapp.Postgres2", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "TablePlus", "DBeaver", "Sequel Ace", "MySQL Workbench", + "pgAdmin", "MongoDB Compass", "RedisInsight", "PostgreSQL", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/com.tableplus.tableplus") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/containers/com.tinyapp.tableplus") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/preferences/com.tableplus.tableplus.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/preferences/com.tinyapp.tableplus.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.tableplus.tableplus") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/caches/com.tinyapp.tableplus") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/dbeaverdata") || path.contains("/.dbeaver") { + evidence.append(ArtifactEvidence(source: .appName, weight: 90)) + } + if path.contains("/application support/dbeaverdata") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/org.jkiss.dbeaver") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/org.jkiss.dbeaver") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/containers/com.sequel-ace.sequel-ace") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/") && path.contains("com.sequel-ace.sequel-ace") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 85)) + } + + if path.contains("/application support/mysql/workbench") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.oracle.mysql.workbench.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/pgadmin") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/org.pgadmin.pgadmin4") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/mongodb compass") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.mongodb.compass") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/redisinsight") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.redis.redisinsight") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.contains("/application support/postgres") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.postgresapp.postgres2") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/preferences/com.postgresapp.postgres2.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/saved application state/com.postgresapp.postgres2") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/usr/local/var/postgres") || path.contains("/opt/homebrew/var/postgresql") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift new file mode 100644 index 0000000..80bb41b --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct DefaultRule: ApplicationRule { + public let displayName = "Generic" + public let supportedBundleIDs: Set = [] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [] + + public init() {} + + public func matches(identity: AppIdentity) -> Bool { true } + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + let name = candidate.lastPathComponent + var evidence: [ArtifactEvidence] = [] + + if name == identity.bundleID || name.hasPrefix(identity.bundleID + ".") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + + if name == identity.appName || name.hasPrefix(identity.appName + " ") || name.hasPrefix(identity.appName + "-") { + evidence.append(ArtifactEvidence(source: .appName, weight: 60)) + } + + if identity.vendorNames.contains(name) || identity.vendorNames.contains(candidate.deletingLastPathComponent().lastPathComponent) { + evidence.append(ArtifactEvidence(source: .rule, weight: 30)) + } + + let parent = candidate.deletingLastPathComponent().lastPathComponent + if parent == "Containers" && name == identity.bundleID { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if parent == "Group Containers" && (name == "group.\(identity.bundleID)" || name.hasPrefix("group.\(identity.bundleID).")) { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + + if path.contains("/launchagents/") || path.contains("/launchdaemons/") { + if path.contains(identity.bundleID.lowercased()) || name.lowercased().contains(identity.appName.lowercased()) { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + } + + if name == identity.executableName { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift new file mode 100644 index 0000000..1d5cf16 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct DockerRule: ApplicationRule { + public let displayName = "Docker" + public let supportedBundleIDs: Set = [ + "com.docker.docker", + "com.docker.orbstack", + "dev.orbstack", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Docker", "OrbStack"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.hasSuffix("/containers/com.docker.docker") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + if path.contains("/group containers/group.com.docker") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application support/docker") { + evidence.append(ArtifactEvidence(source: .appName, weight: 60)) + } + if path.contains("/containers/com.docker.orbstack") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + if path.contains("/caches/com.docker") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + + if path.hasSuffix(".raw") || path.hasSuffix(".qcow2") { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift new file mode 100644 index 0000000..7440a0f --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct ElectronRule: ApplicationRule { + public let displayName = "Electron" + public let supportedBundleIDs: Set = [ + "com.todesktop.230113mitalod2", // Cursor + "com.microsoft.VSCode", + "com.hnc.Discord", + "com.tinyspeck.slackmacgap", + "com.postmanlabs.mac", + "md.obsidian", + "com.notion.Notion", + "com.insomnia.app", + "com.slack.Slack", + "com.microsoft.teams2", + "com.figma.Desktop", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "Cursor", "Visual Studio Code", "VSCode", "VSCodium", + "Discord", "Slack", "Postman", "Obsidian", "Notion", "Insomnia", + "Teams", "Microsoft Teams", + "Figma", + ] + + private let electronArtifactDirs: Set = [ + "Cache", "Code Cache", "GPUCache", + "Local Storage", "Session Storage", + "IndexedDB", "blob_storage", "Service Worker", + "PartitionedStorage", "Network", "Extensions", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + let name = candidate.lastPathComponent + var evidence: [ArtifactEvidence] = [] + + let appSupportPath = "/application support/\(identity.appName.lowercased())" + + if electronArtifactDirs.contains(name) { + if path.contains(appSupportPath) { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + } + + if path.contains(appSupportPath) { + evidence.append(ArtifactEvidence(source: .appName, weight: 60)) + } + + return evidence + } + + public func matches(identity: AppIdentity) -> Bool { + if supportedBundleIDs.contains(identity.bundleID) { return true } + if supportedAppNames.contains(identity.appName) { return true } + if identity.isElectron { return true } + return false + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/EpicGamesRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/EpicGamesRule.swift new file mode 100644 index 0000000..f26ce73 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/EpicGamesRule.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct EpicGamesRule: ApplicationRule { + public let displayName = "Epic Games" + public let supportedBundleIDs: Set = [ + "com.epicgames.EpicGamesLauncher", + "com.epicgames.unrealengine", + ] + public let supportedTeamIDs: Set = [ + "95JQ5223G6", + ] + public let supportedAppNames: Set = [ + "Epic Games Launcher", "Epic Games", + "Unreal Engine", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/epic") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/epic games launcher") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.epicgames.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.epicgames.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/logs/epicgameslauncher") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/saved application state/com.epicgames.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/webcache") && path.contains("epicgames") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/vaultcache") && path.contains("epic") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/application support/epic/vaultcache") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/FinalCutProRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/FinalCutProRule.swift new file mode 100644 index 0000000..790f95b --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/FinalCutProRule.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct FinalCutProRule: ApplicationRule { + public let displayName = "Final Cut Pro" + public let supportedBundleIDs: Set = ["com.apple.FinalCutPro"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Final Cut Pro"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/com.apple.finalcutpro") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application support/final cut pro") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.apple.finalcutpro") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.apple.finalcutpro.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/render files") || path.contains("/backup projects") { + if path.contains("final cut pro") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/GitClientsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/GitClientsRule.swift new file mode 100644 index 0000000..99af7c3 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/GitClientsRule.swift @@ -0,0 +1,84 @@ +import Foundation + +public struct GitClientsRule: ApplicationRule { + public let displayName = "Git Clients" + public let supportedBundleIDs: Set = [ + "com.github.GitHubClient", + "com.torusknot.SourceTreeNotMAS", + "com.DanPristupov.Fork", + "com.fournova.Tower3", + "com.axosoft.GitKraken", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "GitHub Desktop", "Sourcetree", "Fork", "Tower", "GitKraken", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/github desktop") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.github.githubclient") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.github.githubclient.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/logs/github desktop") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + + if path.contains("/application support/sourcetree") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.torusknot.sourcetreenotmas") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.torusknot.sourcetreenotmas.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/logs/sourcetree") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + + if path.contains("/application support/fork") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/com.danpristupov.fork") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/caches/com.danpristupov.fork") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.danpristupov.fork.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/com.fournova.tower3") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/caches/com.fournova.tower3") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.fournova.tower3.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/gitkraken") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.axosoft.gitkraken") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.axosoft.gitkraken.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/HomebrewRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/HomebrewRule.swift new file mode 100644 index 0000000..4564149 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/HomebrewRule.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct HomebrewRule: ApplicationRule { + public let displayName = "Homebrew" + public let supportedBundleIDs: Set = [] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "Homebrew", "brew", + ] + + public init() {} + + public func matches(identity: AppIdentity) -> Bool { + if supportedAppNames.contains(identity.appName) { return true } + if identity.bundleID == "N/A (CLI tool)" { return true } + return false + } + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.hasPrefix("/usr/local/homebrew") || path.hasPrefix("/opt/homebrew") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/caches/homebrew") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/logs/homebrew") { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + if path.contains("/caskroom") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/cellar") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.hasPrefix("/usr/local/bin/brew") || path.hasPrefix("/opt/homebrew/bin/brew") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/JetBrainsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/JetBrainsRule.swift new file mode 100644 index 0000000..3b7606a --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/JetBrainsRule.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct JetBrainsRule: ApplicationRule { + public let displayName = "JetBrains" + public let supportedBundleIDs: Set = [] + public let supportedTeamIDs: Set = ["2YEDZK7QJ8"] + public let supportedAppNames: Set = [ + "IntelliJ IDEA", "PyCharm", "GoLand", "WebStorm", + "DataGrip", "RubyMine", "CLion", "Rider", "AppCode", + "PhpStorm", "Aqua", + ] + + private static let ideNameMap: [String: String] = [ + "com.jetbrains.intellij": "IntelliJIDEA", + "com.jetbrains.pycharm": "PyCharm", + "com.jetbrains.goland": "GoLand", + "com.jetbrains.webstorm": "WebStorm", + "com.jetbrains.datagrip": "DataGrip", + "com.jetbrains.rubymine": "RubyMine", + "com.jetbrains.clion": "CLion", + "com.jetbrains.rider": "Rider", + "com.jetbrains.appcode": "AppCode", + "com.jetbrains.phpstorm": "PhpStorm", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + let ide = Self.ideName(from: identity.bundleID)?.lowercased() ?? identity.appName.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/applicationsupport/jetbrains/\(ide)") { + evidence.append(ArtifactEvidence(source: .appName, weight: 60)) + } + if path.contains("/caches/jetbrains/\(ide)") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + if path.contains("/logs/jetbrains/\(ide)") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/preferences/\(ide)") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + + return evidence + } + + private static func ideName(from bundleID: String) -> String? { + for (prefix, name) in ideNameMap { + if bundleID.lowercased().hasPrefix(prefix.lowercased()) { + return name + } + } + return nil + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/KarabinerElementsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/KarabinerElementsRule.swift new file mode 100644 index 0000000..56fef42 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/KarabinerElementsRule.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct KarabinerElementsRule: ApplicationRule { + public let displayName = "Karabiner Elements" + public let supportedBundleIDs: Set = ["org.pqrs.Karabiner-Elements.Preferences"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Karabiner-Elements", "Karabiner Elements"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/org.pqrs/karabiner-elements") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/preferences/org.pqrs.karabiner-elements.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/launchdaemons/org.pqrs.karabiner") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/extensions/org.pqrs.karabiner") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/LittleSnitchRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/LittleSnitchRule.swift new file mode 100644 index 0000000..aafb14e --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/LittleSnitchRule.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct LittleSnitchRule: ApplicationRule { + public let displayName = "Little Snitch" + public let supportedBundleIDs: Set = ["at.obdev.littlesnitch"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Little Snitch"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/little snitch") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/at.obdev.littlesnitch.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/launchdaemons/at.obdev.littlesnitch") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/LogicProRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/LogicProRule.swift new file mode 100644 index 0000000..3b357ae --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/LogicProRule.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct LogicProRule: ApplicationRule { + public let displayName = "Logic Pro" + public let supportedBundleIDs: Set = ["com.apple.Logic10"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Logic Pro", "Logic Pro X"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/com.apple.logic10") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/application support/logic") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.apple.logic10") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.apple.logic10.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/audio/plug-ins/components") || + path.contains("/audio/plug-ins/vst") { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift new file mode 100644 index 0000000..3583ee5 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift @@ -0,0 +1,91 @@ +import Foundation + +public struct MicrosoftOfficeRule: ApplicationRule { + public let displayName = "Microsoft Office" + public let supportedBundleIDs: Set = [ + "com.microsoft.word", + "com.microsoft.excel", + "com.microsoft.powerpoint", + "com.microsoft.outlook", + "com.microsoft.teams", + "com.microsoft.teams2", + "com.microsoft.onenote.mac", + "com.microsoft.Excel", + "com.microsoft.Word", + "com.microsoft.Powerpoint", + ] + public let supportedTeamIDs: Set = [ + "UBF8T346G9", + ] + public let supportedAppNames: Set = [ + "Microsoft Word", "Word", + "Microsoft Excel", "Excel", + "Microsoft PowerPoint", "PowerPoint", + "Microsoft Outlook", "Outlook", + "Microsoft Teams", "Teams", + "Microsoft OneNote", "OneNote", + "Microsoft AutoUpdate", "Microsoft Office", + ] + + public init() {} + + public func matches(identity: AppIdentity) -> Bool { + if supportedBundleIDs.contains(identity.bundleID) { return true } + if let tid = identity.teamID, supportedTeamIDs.contains(tid) { + let name = identity.appName.lowercased() + if name.hasPrefix("microsoft") || name.contains("office") || name.contains("teams") { + return true + } + } + if supportedAppNames.contains(identity.appName) { return true } + let lower = identity.appName.lowercased() + return lower.hasPrefix("microsoft") + } + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/microsoft") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/microsoft office") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.microsoft.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.microsoft.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/containers/com.microsoft.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/group containers/ubf8t346g9.office") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/group containers/ubf8t346g9.onedrivestandalonesuite") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/logs/microsoft") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/launchdaemons/com.microsoft.autoupdate") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/com.microsoft.autoupdate") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/application support/microsoft/mau2.0") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/saved application state/com.microsoft.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/application support/microsoft/teams") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/NetworkExtensionRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/NetworkExtensionRule.swift new file mode 100644 index 0000000..dd3df78 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/NetworkExtensionRule.swift @@ -0,0 +1,87 @@ +import Foundation + +public struct NetworkExtensionRule: ApplicationRule { + public let displayName = "Network Extension" + public let supportedBundleIDs: Set = [ + "at.obdev.littlesnitch", + "at.obdev.LittleSnitchNetworkExtension", + "com.nordvpn.macos", + "com.expressvpn.ExpressVPN", + "com.tunnelbear.mac", + "com.protonvpn.mac", + "com.windscribe.macos", + "com.surfshark.vpnclient", + "com.privateinternetaccess.PIA", + "com.ipvanish", + "com.vyprvpn.mac", + ] + public let supportedTeamIDs: Set = [ + "TDNWQ5M53F", // ProtonVPN + ] + public let supportedAppNames: Set = [ + "Little Snitch", + "NordVPN", + "ExpressVPN", + "TunnelBear", + "ProtonVPN", + "Windscribe", + "Surfshark", + "Private Internet Access", "PIA", + "IPVanish", + "VyprVPN", + ] + + public init() {} + + public func matches(identity: AppIdentity) -> Bool { + if supportedBundleIDs.contains(identity.bundleID) { return true } + if let tid = identity.teamID, supportedTeamIDs.contains(tid) { return true } + if supportedAppNames.contains(identity.appName) { return true } + let lower = identity.appName.lowercased() + return lower.contains("vpn") || lower.contains("snitch") || lower.contains("firewall") + } + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/little snitch") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/extensions/littlesnitch") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/stagedextensions/") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/launchdaemons/at.obdev.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/at.obdev.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/preferences/at.obdev.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/application support/nordvpn") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/launchdaemons/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/caches/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/systemextensions/") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/NordVPNRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/NordVPNRule.swift new file mode 100644 index 0000000..bd277de --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/NordVPNRule.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct NordVPNRule: ApplicationRule { + public let displayName = "NordVPN" + public let supportedBundleIDs: Set = ["com.nordvpn.macos"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["NordVPN"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/nordvpn") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.nordvpn.macos.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.nordvpn.macos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/launchdaemons/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/com.nordvpn.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift new file mode 100644 index 0000000..3b7f424 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct ParallelsRule: ApplicationRule { + public let displayName = "Parallels Desktop" + public let supportedBundleIDs: Set = ["com.parallels.desktop.console"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Parallels Desktop"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/parallels") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/library/parallels") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/containers/com.parallels.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/") && path.contains("com.parallels.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/applications (parallels)") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + if path.contains("/.parallels_settings") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/users/shared/parallels") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/usr/local/bin/prl") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/preferences/com.parallels.desktop.console.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.parallels.desktop.console") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/application scripts/") && path.contains("com.parallels.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/saved application state/com.parallels.desktop.console") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/launchdaemons/com.parallels.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/com.parallels.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/RancherDesktopRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/RancherDesktopRule.swift new file mode 100644 index 0000000..b8e9038 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/RancherDesktopRule.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct RancherDesktopRule: ApplicationRule { + public let displayName = "Rancher Desktop" + public let supportedBundleIDs: Set = ["io.rancher.desktop"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Rancher Desktop"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/rancher-desktop") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/io.rancher.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/io.rancher.desktop.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/saved application state/io.rancher.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/.local/share/rancher-desktop") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/.rd") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/privilegedhelpertools/io.rancher.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/launchdaemons/io.rancher.desktop") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/RaycastRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/RaycastRule.swift new file mode 100644 index 0000000..0f67ff8 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/RaycastRule.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct RaycastRule: ApplicationRule { + public let displayName = "Raycast" + public let supportedBundleIDs: Set = ["com.raycast.macos"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Raycast"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/com.raycast.macos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/caches/com.raycast.macos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.raycast.macos.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/saved application state/com.raycast.macos") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/SteamRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/SteamRule.swift new file mode 100644 index 0000000..f6e5f35 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/SteamRule.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct SteamRule: ApplicationRule { + public let displayName = "Steam" + public let supportedBundleIDs: Set = [ + "com.valvesoftware.steam", + ] + public let supportedTeamIDs: Set = [ + "MXG3986M2V", + ] + public let supportedAppNames: Set = [ + "Steam", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/steam") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.valvesoftware.steam") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.valvesoftware.steam") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/caches/steam") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + if path.contains("/application support/steam/steamapps") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/application support/steam/userdata") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/application support/steam/steamapps/compatdata") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/application support/steam/steamapps/shadercache") { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + if path.contains("/application support/steam/steamapps/workshop") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/application support/steam/logs") { + evidence.append(ArtifactEvidence(source: .rule, weight: 40)) + } + if path.contains("/saved application state/com.valvesoftware.steam") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/TerminalRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/TerminalRule.swift new file mode 100644 index 0000000..1053110 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/TerminalRule.swift @@ -0,0 +1,106 @@ +import Foundation + +public struct TerminalRule: ApplicationRule { + public let displayName = "Terminal" + public let supportedBundleIDs: Set = [ + "com.googlecode.iterm2", + "dev.warp.Warp-Stable", + "org.tabby", + "co.zeit.hyper", + "org.alacritty", + "com.github.wez.wezterm", + "net.kovidgoyal.kitty", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "iTerm2", "Warp", "Tabby", "Hyper", "Alacritty", "WezTerm", "Kitty", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/preferences/com.googlecode.iterm2.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/application support/iterm2") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.googlecode.iterm2") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/saved application state/com.googlecode.iterm2") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.contains("/.iterm2") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + if path.contains("/application support/dev.warp.warp-stable") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/application support/warp") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/dev.warp.warp-stable") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/dev.warp.warp-stable.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/.warp") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + if path.contains("/application support/tabby") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/org.tabby") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/org.tabby.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/application support/hyper") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/co.zeit.hyper") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/.hyper.js") || path.contains("/.hyper_plugins") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + + if path.contains("/.config/alacritty") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/preferences/org.alacritty.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/.config/wezterm") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/.wezterm.lua") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/preferences/com.github.wez.wezterm.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/.config/kitty") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/.cache/kitty") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/preferences/net.kovidgoyal.kitty.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/UnityRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/UnityRule.swift new file mode 100644 index 0000000..4475cba --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/UnityRule.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct UnityRule: ApplicationRule { + public let displayName = "Unity" + public let supportedBundleIDs: Set = [ + "com.unity3d.unityhub", + "com.unity3d.UnityEditor5.x", + "com.unity3d.UnityEditor", + ] + public let supportedTeamIDs: Set = [ + "7S365J7V36", + ] + public let supportedAppNames: Set = [ + "Unity Hub", "Unity", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/unity") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/unity hub") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/preferences/com.unity3d.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/caches/com.unity3d.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/caches/unity") { + evidence.append(ArtifactEvidence(source: .appName, weight: 50)) + } + if path.contains("/logs/unity") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/logs/unity hub") { + evidence.append(ArtifactEvidence(source: .appName, weight: 40)) + } + if path.contains("/saved application state/com.unity3d.") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + if path.hasSuffix("/.local/share/unity3d") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/library/unity/packagecache") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift new file mode 100644 index 0000000..3aa5bc5 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct VMwareFusionRule: ApplicationRule { + public let displayName = "VMware Fusion" + public let supportedBundleIDs: Set = ["com.vmware.fusion"] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["VMware Fusion"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/vmware fusion") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/application support/vmware") && !path.contains("fusion") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/caches/com.vmware.fusion") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + } + if path.contains("/preferences/com.vmware.fusion.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/preferences/com.vmware.fusionstartmenu.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/virtual machines") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + if path.contains("/preferences/vmware fusion") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/extensions/vmmon.kext") || path.contains("/extensions/vmnet.kext") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + if path.contains("/launchdaemons/com.vmware") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/privilegedhelpertools/com.vmware") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/VirtualizationRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/VirtualizationRule.swift new file mode 100644 index 0000000..422f2c6 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/VirtualizationRule.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct VirtualizationRule: ApplicationRule { + public let displayName = "Virtualization" + public let supportedBundleIDs: Set = [ + "org.virtualbox.app.VirtualBox", + "com.utmapp.UTM", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = [ + "VirtualBox", "UTM", + ] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/application support/virtualbox") { + evidence.append(ArtifactEvidence(source: .appName, weight: 70)) + } + if path.contains("/virtualbox vms") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + if path.contains("/usr/local/bin/vboximg-mount") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/preferences/org.virtualbox.app.virtualbox") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/containers/com.utmapp") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/group containers/") && path.contains("com.utmapp") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 85)) + } + if path.contains("/application scripts/") && path.contains("com.utmapp") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + + if path.contains("/opt/homebrew/bin/qemu-system-") || path.contains("/usr/local/bin/qemu-system-") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/.config/qemu") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/XcodeRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/XcodeRule.swift new file mode 100644 index 0000000..2877b0a --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/Rules/XcodeRule.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct XcodeRule: ApplicationRule { + public let displayName = "Xcode" + public let supportedBundleIDs: Set = [ + "com.apple.dt.Xcode", + "com.apple.dt.xcode", + ] + public let supportedTeamIDs: Set = [] + public let supportedAppNames: Set = ["Xcode"] + + public init() {} + + public func evidence(for candidate: URL, identity: AppIdentity) -> [ArtifactEvidence] { + let path = candidate.path.lowercased() + var evidence: [ArtifactEvidence] = [] + + if path.contains("/containers/com.apple.dt.xcode") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) + } + if path.contains("/application scripts/com.apple.dt.xcode") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/developer/xcode/userdata") { + evidence.append(ArtifactEvidence(source: .rule, weight: 60)) + } + if path.contains("/developer/xcode/devicesupport") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/mobiledevice/provisioning profiles") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + if path.contains("/preferences/com.apple.dt.xcode.plist") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + if path.contains("/saved application state/com.apple.dt.xcode.savedstate") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 60)) + } + + return evidence + } +} diff --git a/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift b/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift new file mode 100644 index 0000000..88d519f --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct ScoringWeights: Sendable, Equatable { + public var bundleIDExact: Int = 100 + public var bundleIDPrefix: Int = 90 + public var appNameExact: Int = 60 + public var appNamePrefix: Int = 50 + public var executableName: Int = 70 + public var frameworkName: Int = 60 + public var xpcServiceName: Int = 50 + public var plugInName: Int = 40 + public var vendorName: Int = 30 + + public var teamID: Int = 50 + public var developerSignature: Int = 40 + + public var launchAgent: Int = 70 + public var launchDaemon: Int = 70 + public var loginItem: Int = 90 + public var appGroup: Int = 70 + public var container: Int = 70 + public var `extension`: Int = 70 + public var xpcConnection: Int = 60 + + public var packageReceipt: Int = 100 + public var plistContent: Int = 80 + + public var spotlight: Int = 5 + public var spotlightBundleAttr: Int = 100 + public var spotlightCreator: Int = 50 + + public var fileContent: Int = 60 + public var electronCache: Int = 40 + public var jetBrainsConfig: Int = 60 + public var flutterBuild: Int = 50 + + public var parentDirectory: Int = 25 + + public var launchServicesRegistered: Int = 50 + + public static let `default` = ScoringWeights() + public static let test = ScoringWeights() + + public init() {} + + public func weight(for evidence: Evidence) -> Int { + switch evidence { + case .bundleIDExact: return bundleIDExact + case .bundleIDPrefix: return bundleIDPrefix + case .appNameExact: return appNameExact + case .appNamePrefix: return appNamePrefix + case .executableName: return executableName + case .frameworkName: return frameworkName + case .xpcServiceName: return xpcServiceName + case .plugInName: return plugInName + case .vendorName: return vendorName + case .teamID: return teamID + case .developerSignature: return developerSignature + case .launchAgent: return launchAgent + case .launchDaemon: return launchDaemon + case .loginItem: return loginItem + case .appGroup: return appGroup + case .container: return container + case .extension: return self.extension + case .xpcConnection: return xpcConnection + case .packageReceipt: return packageReceipt + case .plistContent: return plistContent + case .spotlight: return spotlight + case .spotlightBundleAttr: return spotlightBundleAttr + case .spotlightCreator: return spotlightCreator + case .fileContent: return fileContent + case .electronCache: return electronCache + case .jetBrainsConfig: return jetBrainsConfig + case .flutterBuild: return flutterBuild + case .parentDirectory: return parentDirectory + case .launchServicesRegistered: return launchServicesRegistered + } + } + + public func score(_ evidence: Set) -> Int { + evidence.reduce(0) { $0 + weight(for: $1) } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/SnapshotStore.swift b/MacOSCleaner/Features/Uninstaller/SnapshotStore.swift new file mode 100644 index 0000000..d61e877 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/SnapshotStore.swift @@ -0,0 +1,77 @@ +import Foundation +import OSLog + +private extension Logger { + static let snapshot = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "SnapshotStore") +} + +public actor SnapshotStore { + private let storageURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let fileManager: FileManager + + public init( + storageURL: URL? = nil, + fileManager: FileManager = .default + ) { + let appSupport = storageURL ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + self.storageURL = appSupport.appendingPathComponent("MacOSCleaner/Snapshots", isDirectory: true) + self.fileManager = fileManager + self.encoder = JSONEncoder() + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.decoder = JSONDecoder() + } + + private func ensureDirectory() throws { + try fileManager.createDirectory(at: storageURL, withIntermediateDirectories: true) + } + + private func fileURL(for id: UUID) -> URL { + storageURL.appendingPathComponent("\(id.uuidString).json") + } + + public func save(snapshot: UninstallSnapshot) throws { + try ensureDirectory() + let data = try encoder.encode(snapshot) + let url = fileURL(for: snapshot.id) + try data.write(to: url, options: .atomic) + Logger.snapshot.info("Saved snapshot '\(snapshot.id.uuidString, privacy: .public)' for '\(snapshot.appName, privacy: .public)'") + } + + public func load(id: UUID) throws -> UninstallSnapshot? { + let url = fileURL(for: id) + guard fileManager.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + return try decoder.decode(UninstallSnapshot.self, from: data) + } + + public func list() throws -> [UninstallSnapshot] { + try ensureDirectory() + let contents = try fileManager.contentsOfDirectory(at: storageURL, includingPropertiesForKeys: [.creationDateKey]) + let jsonFiles = contents.filter { $0.pathExtension == "json" } + var snapshots: [UninstallSnapshot] = [] + for url in jsonFiles { + guard let data = try? Data(contentsOf: url), + let snapshot = try? decoder.decode(UninstallSnapshot.self, from: data) else { + continue + } + snapshots.append(snapshot) + } + return snapshots.sorted { $0.timestamp > $1.timestamp } + } + + public func delete(id: UUID) throws { + let url = fileURL(for: id) + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + Logger.snapshot.info("Deleted snapshot '\(id.uuidString, privacy: .public)'") + } + } + + public func snapshotCount() throws -> Int { + try ensureDirectory() + let contents = try fileManager.contentsOfDirectory(at: storageURL, includingPropertiesForKeys: nil) + return contents.filter { $0.pathExtension == "json" }.count + } +} diff --git a/MacOSCleaner/Features/Uninstaller/UninstallSnapshot.swift b/MacOSCleaner/Features/Uninstaller/UninstallSnapshot.swift new file mode 100644 index 0000000..d208a4e --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/UninstallSnapshot.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct UninstallSnapshot: Codable, Sendable, Identifiable { + public let id: UUID + public let timestamp: Date + public let appName: String + public let bundleID: String + public let appVersion: String? + public let appBundlePath: String + public let deletedPaths: [String] + public let bypassTrash: Bool + + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + appName: String, + bundleID: String, + appVersion: String?, + appBundlePath: String, + deletedPaths: [String], + bypassTrash: Bool + ) { + self.id = id + self.timestamp = timestamp + self.appName = appName + self.bundleID = bundleID + self.appVersion = appVersion + self.appBundlePath = appBundlePath + self.deletedPaths = deletedPaths + self.bypassTrash = bypassTrash + } +} diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index 12ff27f..af1e03f 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -13,7 +13,7 @@ public final class ScanProgress: @unchecked Sendable { public var totalSteps: Int = 1 public var message: String = "" public var percentage: Double = 0.0 - + public init() {} } @@ -23,6 +23,10 @@ public actor UninstallerService { private let safetyManager: SafetyManager private let trashManager: TrashManager private let commandRunner: CommandRunner + private let identityCache = IdentityCache() + private let codesignCache = CodesignCache() + private let plistCache = PlistContentCache() + private let ruleRegistry: ApplicationRuleRegistry public init( fileManager: FileManager = .default, @@ -34,27 +38,44 @@ public actor UninstallerService { self.safetyManager = safetyManager self.trashManager = trashManager self.commandRunner = commandRunner + self.ruleRegistry = ApplicationRuleRegistry.createDefault() } + // MARK: - Types + public enum DeletionRisk: String, Sendable, CaseIterable { case safe case normal } + public enum ScanState: Equatable, Sendable { + case queued + case discovered + case scanning(progress: Double?) + case deepScanned + case failed(String) + } + public struct RelatedCleanupComponent: Identifiable, Sendable, Hashable { public let id = UUID() public let title: String public let category: CleanupCategory public let sizeBytes: Int64 + public let url: URL + public var isSelected: Bool = true - public init(title: String, category: CleanupCategory, sizeBytes: Int64) { + public init(title: String, category: CleanupCategory, sizeBytes: Int64, url: URL, isSelected: Bool = true) { self.title = title self.category = category self.sizeBytes = sizeBytes + self.url = url + self.isSelected = isSelected } public func hash(into hasher: inout Hasher) { hasher.combine(id) } - public static func == (lhs: RelatedCleanupComponent, rhs: RelatedCleanupComponent) -> Bool { lhs.id == rhs.id } + public static func == (lhs: RelatedCleanupComponent, rhs: RelatedCleanupComponent) -> Bool { + lhs.id == rhs.id && lhs.isSelected == rhs.isSelected && lhs.sizeBytes == rhs.sizeBytes + } } public struct RelatedFile: Identifiable, Sendable, Hashable { @@ -63,16 +84,22 @@ public actor UninstallerService { public var isSelected: Bool = true public let size: Int64 public let deletionRisk: DeletionRisk + public let evidence: Set + public let confidence: ConfidenceTier - public init(url: URL, isSelected: Bool = true, size: Int64 = 0, deletionRisk: DeletionRisk = .normal) { + public init(url: URL, isSelected: Bool = true, size: Int64 = 0, deletionRisk: DeletionRisk = .normal, evidence: Set = [], confidence: ConfidenceTier = .possible) { self.url = url self.isSelected = isSelected self.size = size self.deletionRisk = deletionRisk + self.evidence = evidence + self.confidence = confidence } public func hash(into hasher: inout Hasher) { hasher.combine(id) } - public static func == (lhs: RelatedFile, rhs: RelatedFile) -> Bool { lhs.id == rhs.id } + public static func == (lhs: RelatedFile, rhs: RelatedFile) -> Bool { + lhs.id == rhs.id && lhs.isSelected == rhs.isSelected && lhs.size == rhs.size && lhs.confidence == rhs.confidence + } } public struct AppInfo: Identifiable, Sendable, Hashable { @@ -82,142 +109,47 @@ public actor UninstallerService { public let name: String public var relatedFiles: [RelatedFile] = [] public var developerComponents: [RelatedCleanupComponent] = [] + public var identity: AppIdentity? + public var scanState: ScanState = .discovered public var size: Int64 = 0 public var version: String = "" public var lastUsed: Date? = nil public var iconData: Data? = nil - + public func hash(into hasher: inout Hasher) { hasher.combine(id) } - public static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { lhs.id == rhs.id } - + public static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { + lhs.id == rhs.id && + lhs.relatedFiles == rhs.relatedFiles && + lhs.developerComponents == rhs.developerComponents && + lhs.scanState == rhs.scanState && + lhs.size == rhs.size + } + public var totalSize: Int64 { let relatedSize = relatedFiles.filter(\.isSelected).reduce(0) { $0 + $1.size } - return size + relatedSize + let devSize = developerComponents.filter(\.isSelected).reduce(0) { $0 + $1.sizeBytes } + return size + relatedSize + devSize } } - // MARK: - Developer Components Detection - - /// Maps known apps to related developer infrastructure components managed by Smart Cleanup. - private func detectDeveloperComponents(appName: String, bundleID: String?) async -> [RelatedCleanupComponent] { - let home = NSHomeDirectory() - var components: [RelatedCleanupComponent] = [] - let lowerName = appName.lowercased() - let lowerID = bundleID?.lowercased() ?? "" - - if lowerName.contains("android studio") || lowerID.contains("android.studio") { - let sdkURL = URL(fileURLWithPath: "\(home)/Library/Android/sdk") - if fileManager.fileExists(atPath: sdkURL.path) { - let sdkSize = await getDirectorySize(url: sdkURL) - if sdkSize > 0 { - components.append(RelatedCleanupComponent(title: "Android SDK", category: .androidSDK, sizeBytes: sdkSize)) - } - } - let gradleURL = URL(fileURLWithPath: "\(home)/.gradle") - if fileManager.fileExists(atPath: gradleURL.path) { - let gradleSize = await getDirectorySize(url: gradleURL) - if gradleSize > 0 { - components.append(RelatedCleanupComponent(title: "Gradle Cache", category: .gradleMaven, sizeBytes: gradleSize)) - } - } - } - - if lowerID == "com.apple.dt.xcode" || (lowerName == "xcode" && lowerID.hasPrefix("com.apple.dt")) { - let derivedURL = URL(fileURLWithPath: "\(home)/Library/Developer/Xcode/DerivedData") - if fileManager.fileExists(atPath: derivedURL.path) { - let derivedSize = await getDirectorySize(url: derivedURL) - if derivedSize > 0 { - components.append(RelatedCleanupComponent(title: "Xcode DerivedData", category: .xcode, sizeBytes: derivedSize)) - } - } - let simURL = URL(fileURLWithPath: "\(home)/Library/Developer/CoreSimulator") - if fileManager.fileExists(atPath: simURL.path) { - let simSize = await getDirectorySize(url: simURL) - if simSize > 0 { - components.append(RelatedCleanupComponent(title: "iOS Simulators", category: .iosSimulators, sizeBytes: simSize)) - } - } - } - - if lowerName == "flutter" || lowerID.contains("flutter") { - let flutterURL = URL(fileURLWithPath: "\(home)/.pub-cache") - if fileManager.fileExists(atPath: flutterURL.path) { - let flutterSize = await getDirectorySize(url: flutterURL) - if flutterSize > 0 { - components.append(RelatedCleanupComponent(title: "Flutter/Dart Cache", category: .flutterDart, sizeBytes: flutterSize)) - } - } - } - - if lowerName.contains("orbstack") || lowerID == "dev.orbstack" || lowerName.contains("docker") || lowerID == "com.docker.docker" { - let dockerURL = URL(fileURLWithPath: "\(home)/Library/Containers/com.docker.docker") - if fileManager.fileExists(atPath: dockerURL.path) { - let dockerSize = await getDirectorySize(url: dockerURL) - if dockerSize > 0 { - components.append(RelatedCleanupComponent(title: "Docker", category: .docker, sizeBytes: dockerSize)) - } - } - } - - if lowerName == "homebrew" || lowerID == "com.homebrew" { - let brewURL = URL(fileURLWithPath: "/opt/homebrew") - if fileManager.fileExists(atPath: brewURL.path) { - let brewSize = await getDirectorySize(url: brewURL) - if brewSize > 0 { - components.append(RelatedCleanupComponent(title: "Homebrew", category: .packageManagers, sizeBytes: brewSize)) - } - } - } - - return components - } + // MARK: - Scan All Applications (Discovery only) public func scanAllApplications() async throws -> [AppInfo] { - let appDirs = [ - URL(fileURLWithPath: "/Applications"), - fileManager.urls(for: .applicationDirectory, in: .userDomainMask)[0] - ] - - // Count apps first for progress - var allAppURLsBuilder: [URL] = [] - for dir in appDirs { - if let contents = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { - allAppURLsBuilder.append(contentsOf: contents.filter { $0.pathExtension == "app" }) - } - } - - // Also find apps in Application Support (like Google Updater) - if let home = ProcessInfo.processInfo.environment["HOME"] { - let appSupportDir = home + "/Library/Application Support" - do { - let result = try await commandRunner.run(command: "/usr/bin/find", arguments: [appSupportDir, "-maxdepth", "4", "-name", "*.app", "-type", "d", "-prune"]) - let paths = result.stdout.components(separatedBy: .newlines) - for path in paths where !path.isEmpty { - allAppURLsBuilder.append(URL(fileURLWithPath: path)) - } - } catch { - Logger.uninstaller.warning("Failed to search Application Support for apps: \(error.localizedDescription, privacy: .public)") - } - } - - // Find developer build products (Xcode DerivedData, Flutter build dirs) - let devBuildApps = await scanDeveloperBuildProducts() - allAppURLsBuilder.append(contentsOf: devBuildApps.map(\.url)) - - let allAppURLs = Array(Set(allAppURLsBuilder)) // Remove duplicates - + let discovery = AppDiscovery(commandRunner: commandRunner) + let urls = await discovery.findAll() + await MainActor.run { progress.currentStep = 0 - progress.totalSteps = allAppURLs.count - progress.message = "Scanning applications..." + progress.totalSteps = urls.count + progress.message = "uninstaller.progress.discovering".localized progress.percentage = 0.0 } - + return try await withThrowingTaskGroup(of: AppInfo?.self) { group in - for url in allAppURLs { + for url in urls { group.addTask { - let app = try? await self.scan(appURL: url) + let app = try? await self.discoverAndIndex(url) await MainActor.run { self.progress.currentStep += 1 self.progress.percentage = Double(self.progress.currentStep) / Double(self.progress.totalSteps) @@ -225,560 +157,181 @@ public actor UninstallerService { return app } } - + var apps: [AppInfo] = [] for try await app in group { - if let app = app { - apps.append(app) - } - } - - var mergedApps: [String: AppInfo] = [:] - for app in apps { - let key = "\(app.bundleID ?? "")-\(app.name)" - if let existing = mergedApps[key] { - var mainApp = existing - var secondaryApp = app - - if app.url.path.hasPrefix("/Applications") && !existing.url.path.hasPrefix("/Applications") { - mainApp = app - secondaryApp = existing - } - - var newRelated = mainApp.relatedFiles - newRelated.append(contentsOf: secondaryApp.relatedFiles) - newRelated.append(RelatedFile(url: secondaryApp.url, size: secondaryApp.size)) - - var uniqueRelated: [URL: RelatedFile] = [:] - for file in newRelated { - if uniqueRelated[file.url] == nil { - uniqueRelated[file.url] = file - } - } - - mainApp.relatedFiles = Array(uniqueRelated.values).sorted { $0.url.path < $1.url.path } - mergedApps[key] = mainApp - } else { - mergedApps[key] = app - } - } - - await MainActor.run { - progress.message = "Detecting developer components..." - progress.percentage = 0.95 + if let app = app { apps.append(app) } } - - for (key, app) in mergedApps { - let components = await detectDeveloperComponents(appName: app.name, bundleID: app.bundleID) - if !components.isEmpty { - mergedApps[key]?.developerComponents = components - } - } - + + let merged = mergeApps(apps) + await MainActor.run { - progress.message = "Scan complete" + progress.message = "uninstaller.progress.complete".localized progress.percentage = 1.0 } - - return mergedApps.values.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } - } - } - private func scanDeveloperBuildProducts() async -> [AppInfo] { - guard let home = ProcessInfo.processInfo.environment["HOME"] else { return [] } - - var devApps: [AppInfo] = [] - - // 1. Scan Xcode DerivedData for build products - let derivedDataPath = "\(home)/Library/Developer/Xcode/DerivedData" - do { - let result = try await commandRunner.run( - command: "/usr/bin/find", - arguments: [derivedDataPath, "-maxdepth", "5", "-name", "*.app", "-type", "d", "-prune"] - ) - let paths = result.stdout.components(separatedBy: .newlines) - for path in paths where !path.isEmpty { - let url = URL(fileURLWithPath: path) - // Skip if already found in standard app dirs - if url.path.contains("/Applications/") || url.path.contains("/Application Support/") { - continue - } - if let scanned = try? await scan(appURL: url) { - devApps.append(scanned) - } - } - } catch { - Logger.uninstaller.warning("Failed to scan DerivedData: \(error.localizedDescription, privacy: .public)") - } - - // 2. Scan Flutter project build directories (common locations) - let flutterPaths = [ - "\(home)/Documents", - "\(home)/Desktop", - "\(home)/Projects", - "\(home)/Development", - "\(home)/dev", - "\(home)/repos" - ] - - for basePath in flutterPaths { - guard fileManager.fileExists(atPath: basePath) else { continue } - do { - // Find flutter project build dirs with .app products - let result = try await commandRunner.run( - command: "/usr/bin/find", - arguments: [basePath, "-maxdepth", "5", "-path", "*/build/ios/iphoneos/*.app", "-type", "d", "-prune"] - ) - let paths = result.stdout.components(separatedBy: .newlines) - for path in paths where !path.isEmpty { - let url = URL(fileURLWithPath: path) - if let scanned = try? await scan(appURL: url) { - devApps.append(scanned) - } - } - - // Also find macOS Flutter build products - let macResult = try await commandRunner.run( - command: "/usr/bin/find", - arguments: [basePath, "-maxdepth", "5", "-path", "*/build/macos/Build/Products/*/*.app", "-type", "d", "-prune"] - ) - let macPaths = macResult.stdout.components(separatedBy: .newlines) - for path in macPaths where !path.isEmpty { - let url = URL(fileURLWithPath: path) - if let scanned = try? await scan(appURL: url) { - devApps.append(scanned) - } - } - } catch { - // Skip directories we can't access - continue - } + return merged.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } - - return devApps } - public func scan(appURL: URL) async throws -> AppInfo { - try safetyManager.validate(url: appURL) - - let bundle = Bundle(url: appURL) - let bundleID = bundle?.bundleIdentifier - let appName = appURL.deletingPathExtension().lastPathComponent - let infoDictionary = bundle?.infoDictionary - let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? - infoDictionary?["CFBundleVersion"] as? String ?? "N/A" - - let size = await getDirectorySize(url: appURL) + private func discoverAndIndex(_ url: URL) async throws -> AppInfo { + try safetyManager.validate(url: url) + + let identity = await AppIdentity.resolve(from: url, commandRunner: commandRunner) + await identityCache.set(bundleID: identity.bundleID, identity: identity) + + let size = await getDirectorySize(url: url) let iconData = await MainActor.run { - NSWorkspace.shared.icon(forFile: appURL.path).tiffRepresentation + NSWorkspace.shared.icon(forFile: url.path).tiffRepresentation } - - let mdItem = MDItemCreate(nil, appURL.path as CFString) + let mdItem = MDItemCreate(nil, url.path as CFString) let lastUsed = MDItemCopyAttribute(mdItem, kMDItemLastUsedDate) as? Date - - var relatedURLs = Set() - var searchPatterns = createSearchPatterns(bundleID: bundleID, appName: appName) - - // Detect Electron-based apps and add pattern to find Electron helper processes - let electronFrameworkPath = appURL.appendingPathComponent("Contents/Frameworks/Electron Framework.framework") - if fileManager.fileExists(atPath: electronFrameworkPath.path) { - searchPatterns.append("Electron") - } - - // Detect Java-based apps (Android Studio, etc.) - let javaPath = appURL.appendingPathComponent("Contents/PlugIns/jdk-bundle") - if fileManager.fileExists(atPath: javaPath.path) { - searchPatterns.append("jdk-bundle") - } - - // Pass 1: mdfind (Spotlight) - let mdfindResults = await runMdfind(bundleID: bundleID, appName: appName) - relatedURLs.formUnion(mdfindResults) - - // Pass 2: pkgutil (Receipts) - relatedURLs.formUnion(await getPkgFiles(bundleID: bundleID)) - - // Pass 3: Manual scanning of expanded paths (Depth search) - var libraryPaths = [ - "~/Library/Application Support", - "~/Library/Caches", - "~/Library/Containers", - "~/Library/Group Containers", - "~/Library/Cookies", - "~/Library/Logs", - "~/Library/Preferences", - "~/Library/Saved Application State", - "~/Library/LaunchAgents", - "~/Library/Application Scripts", - "~/Library/HTTPStorages", - "~/Library/WebKit", - "~/Library/Developer/Xcode", - "~/Library/Developer/CoreSimulator", - "~/Library/Caches/CocoaPods", - "~/Library/Caches/com.apple.dt.Xcode", - "~/Library/Caches/org.swift.swiftpm", - "~/Library/Android", - "~/.android", - "~/.gradle", - "~/Library/Caches/com.google.android.studio", - "~/Library", - "~/Library/Developer", - "~/", - "/Library/Application Support", - "/Library/Caches", - "/Library/LaunchAgents", - "/Library/LaunchDaemons", - "/Library/Preferences", - "/Library/PrivilegedHelperTools", - "/tmp", - "/private/tmp", - "/usr/local/bin", - "/usr/local/share", - "/Library/Frameworks", - "/Library/Internet Plug-Ins", - - // Shared file lists (recent documents) - "~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", - - // Per-host preferences - "~/Library/Preferences/ByHost", - - // Google-specific paths - "~/Library/Google", - - // User-level system extension paths - "~/Library/Fonts", - "~/Library/QuickLook", - "~/Library/Screen Savers", - "~/Library/Internet Plug-Ins", - "~/Library/LaunchDaemons", - "~/Library/Frameworks", - "~/Library/Input Methods", - "~/Library/Audio/Plug-Ins", - - // Receipts database - "/private/var/db/receipts", - - // System-level extension paths - "/Library/QuickLook", - "/Library/Screen Savers", - "/Library/Input Methods", - "/Library/Audio/Plug-Ins" - ] - - libraryPaths.append(contentsOf: getSystemSearchPaths()) - - let teamID = await getTeamIdentifier(url: appURL) - let deepScanFolders = ["Application Support", "Caches", "Logs", "Developer", "Containers", "Group Containers", "HTTPStorages", "WebKit", "Preferences", "Application Scripts", "Google", "ByHost", "Android", "gradle"] - - for path in libraryPaths { - let expandedPath = (path as NSString).expandingTildeInPath - let folderURL = URL(fileURLWithPath: expandedPath) - - // Shallow scan first level - let contents = (try? fileManager.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: [])) ?? [] - - for fileURL in contents { - if fileURL.path == appURL.path { continue } - - let fileName = fileURL.lastPathComponent - - // If scanning HOME, only look at hidden files or if it matches a pattern exactly - if path == "~/" || path == "~" { - if !fileName.hasPrefix(".") && !matches(fileName: fileName, patterns: searchPatterns) { - continue - } - } - // Check by pattern - if matches(fileName: fileName, patterns: searchPatterns) { - relatedURLs.insert(fileURL) - } - // Check by Team ID for binaries/kexts in specific folders - else if (path.contains("Launch") || path.contains("Privileged") || path.contains("Extensions")) { - let fileTeamID = await getTeamIdentifier(url: fileURL) - if fileTeamID == teamID && teamID != nil { - relatedURLs.insert(fileURL) - } - } - // Recursive check for vendor folders up to 4 levels deep - else if deepScanFolders.contains(folderURL.lastPathComponent) { - relatedURLs.formUnion(deepSearch(in: fileURL, patterns: searchPatterns, currentDepth: 1, maxDepth: 4, teamID: teamID)) - } - } - } - - // Pass 4: Developer build product specific related files - // If app is inside DerivedData, add the project folder and dev caches - if let home = ProcessInfo.processInfo.environment["HOME"] { - let derivedDataPath = "\(home)/Library/Developer/Xcode/DerivedData" - if appURL.path.hasPrefix(derivedDataPath + "/") { - // Find the project folder in DerivedData (e.g., DerivedData/ProjectName-xyz/) - let pathAfterDerived = String(appURL.path.dropFirst(derivedDataPath.count + 1)) - if let firstComponent = pathAfterDerived.components(separatedBy: "/").first { - let projectFolder = URL(fileURLWithPath: derivedDataPath).appendingPathComponent(firstComponent) - if fileManager.fileExists(atPath: projectFolder.path) { - relatedURLs.insert(projectFolder) - } - } - - // Add common Xcode dev caches - let devCachePaths = [ - "\(home)/Library/Caches/CocoaPods", - "\(home)/Library/Caches/com.apple.dt.Xcode", - "\(home)/Library/Caches/org.swift.swiftpm" - ] - for cachePath in devCachePaths { - let cacheURL = URL(fileURLWithPath: cachePath) - if fileManager.fileExists(atPath: cachePath) { - relatedURLs.insert(cacheURL) - } - } - } - - // If app is in a Flutter project build dir, add the build folder - let flutterBuildPatterns = ["/build/ios/iphoneos/", "/build/macos/Build/Products/"] - for pattern in flutterBuildPatterns { - if appURL.path.contains(pattern) { - // Find the project root (go up from build/) - var projectRoot = appURL.deletingLastPathComponent() // Products/ - projectRoot = projectRoot.deletingLastPathComponent() // Build/ - projectRoot = projectRoot.deletingLastPathComponent() // build/ - if fileManager.fileExists(atPath: projectRoot.path) { - relatedURLs.insert(projectRoot) - } - break - } - } - } - - // Deduplicate: remove parent URLs if a child URL is already in the set - let sortedURLs = relatedURLs.sorted { $0.path.count < $1.path.count } - var deduplicated = Set() - for url in sortedURLs { - let isChild = deduplicated.contains { url.path.hasPrefix($0.path + "/") } - if !isChild { - deduplicated.insert(url) - } - } - - // Verification & Size Calculation - var related: [RelatedFile] = [] - for url in deduplicated { - // Safety check - if (try? safetyManager.validate(url: url)) == nil { continue } - if url.path == appURL.path || appURL.path.hasPrefix(url.path + "/") { continue } - - var isDir: ObjCBool = false - if fileManager.fileExists(atPath: url.path, isDirectory: &isDir) { - let fileSize = await getDirectorySize(url: url) - related.append(RelatedFile(url: url, size: fileSize)) - } - } - return AppInfo( - url: appURL, - bundleID: bundleID, - name: appName, - relatedFiles: related.sorted { $0.url.path < $1.url.path }, + url: url, + bundleID: identity.bundleID, + name: identity.appName, + relatedFiles: [], + developerComponents: [], + identity: identity, + scanState: .discovered, size: size, - version: version, + version: version(from: url), lastUsed: lastUsed, iconData: iconData ) } - private func deepSearch(in url: URL, patterns: [String], currentDepth: Int, maxDepth: Int, teamID: String?) -> Set { - var found = Set() - if currentDepth > maxDepth { return found } - - let contents = (try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])) ?? [] - for fileURL in contents { - let fileName = fileURL.lastPathComponent - var matched = false - - if matches(fileName: fileName, patterns: patterns) { - found.insert(fileURL) - matched = true - } else if let tID = teamID, url.path.contains("Launch") || url.path.contains("Privileged") || url.path.contains("Extensions") || url.path.contains("Application Scripts") { - // If we're deep inside scripts, we can also check for team IDs - // Though getTeamIdentifier is async, we can't await it here unless we make deepSearch async. - // Let's just check if fileName contains TeamID, which is often the case for Group Containers and App Scripts! - if fileName.contains(tID) { - found.insert(fileURL) - matched = true - } - } - - if !matched { - var isDir: ObjCBool = false - if fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDir), isDir.boolValue { - found.formUnion(deepSearch(in: fileURL, patterns: patterns, currentDepth: currentDepth + 1, maxDepth: maxDepth, teamID: teamID)) - } - } + // MARK: - Deep Forensics + + public func deepScan(_ app: AppInfo) async throws -> AppInfo { + let identity: AppIdentity + if let existing = app.identity { + identity = existing + } else { + identity = await AppIdentity.resolve(from: app.url, commandRunner: commandRunner) } - return found - } - private func getDirectorySize(url: URL) async -> Int64 { - fileManager.getDirectorySize(url: url, excludedPaths: []) - } + var updated = app + updated.identity = identity + updated.scanState = .scanning(progress: 0.0) - func getSystemSearchPaths() -> [String] { - var paths = [String]() - - // Scan ALL /private/var/folders/ subdirectories (T/, C/, X/, etc.) - // Each user session gets a unique UUID under a short prefix directory - let varFoldersPath = "/private/var/folders" - if let shortDirs = try? fileManager.contentsOfDirectory(atPath: varFoldersPath) { - for shortDir in shortDirs where !shortDir.hasPrefix(".") { - let shortPath = "\(varFoldersPath)/\(shortDir)" - if let uuidDirs = try? fileManager.contentsOfDirectory(atPath: shortPath) { - for uuidDir in uuidDirs where !uuidDir.hasPrefix(".") { - let uuidPath = "\(shortPath)/\(uuidDir)" - paths.append(uuidPath) - } - } - } - } - - // Also include NSTemporaryDirectory() as fallback - let tmpDir = NSTemporaryDirectory() - if !tmpDir.isEmpty && !paths.contains(tmpDir) { - paths.append(tmpDir) - } - - return paths + let graph = EvidenceGraph(identity: identity) + + async let relatedTask: [RelatedFile] = runDeepRelatedFiles(identity: identity, graph: graph) + async let developerTask: [RelatedCleanupComponent] = DeveloperComponentsDetector.detect( + appName: identity.appName, + bundleID: identity.bundleID + ) + + let (related, developer) = await (relatedTask, developerTask) + updated.relatedFiles = related + updated.developerComponents = developer + updated.scanState = .deepScanned + + return updated } - func createSearchPatterns(bundleID: String?, appName: String) -> [String] { - var patterns = Set() - if let bundleID = bundleID { - patterns.insert(bundleID) - let parts = bundleID.components(separatedBy: CharacterSet(charactersIn: ".-")) - if parts.count >= 2 { - for i in 1..= 3 { - patterns.insert(suffix) - } - } - } - // Add parts >= 4 chars to handle things like 'todesktop' - for part in parts where part.count >= 4 { - if !["com", "org", "net", "apple", "mac", "app"].contains(part.lowercased()) { - patterns.insert(part) - } + private func runDeepRelatedFiles(identity: AppIdentity, graph: EvidenceGraph) async -> [RelatedFile] { + let collector = CandidateCollector(commandRunner: commandRunner) + let candidates = await collector.collect(identity: identity) + let probe = EvidenceProbe(commandRunner: commandRunner, codesignCache: codesignCache, plistCache: plistCache) + + // Record evidence + for url in candidates { + let evidences = await probe.probe(url: url, identity: identity) + await graph.record(evidences, for: url) + + // Attach via ParentLinker + let links = ParentLinker.link(url: url, identity: identity) + for (parent, via) in links { + await graph.attach(url, to: parent, via: via) } } - - let cleanedAppName = appName.replacingOccurrences(of: " ", with: "") - patterns.insert(appName) - patterns.insert(cleanedAppName) - patterns.insert(appName.replacingOccurrences(of: " ", with: "-")) - - // Split app name into words (e.g., "Android Studio" -> "Android", "Studio") - let words = appName.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "-."))) - for word in words where word.count >= 4 { - patterns.insert(word) - } - - // Add extra patterns for known apps - patterns.formUnion(getExtraPatterns(appName: appName, bundleID: bundleID)) - - return Array(patterns).filter { $0.count >= 3 } - } - func getExtraPatterns(appName: String, bundleID: String?) -> [String] { - var extra: [String] = [] - let lowerName = appName.lowercased() - let lowerID = bundleID?.lowercased() ?? "" - - if lowerName.contains("xcode") || lowerID.contains("com.apple.dt.xcode") { - extra.append(contentsOf: ["Instruments", "Simulator", "iphonesimulator", "llvm", "clang"]) - } - if lowerName.contains("android") || lowerID.contains("android") { - extra.append(contentsOf: ["android", "emulator", "gradle", "jetbrains", "studio", "sdk", "avd"]) - } - if lowerName.contains("flutter") || lowerID.contains("flutter") { - extra.append(contentsOf: ["mobileinstallation", "flutter", "dart"]) - } - if lowerName.contains("cleaner") || lowerID.contains("cleaner") { - extra.append("macoscleaner") - } - if lowerName.contains("orbstack") || lowerID.contains("orbstack") { - extra.append(contentsOf: ["macvirt", "orbstack", "docker"]) - } - if lowerName.contains("chrome") || lowerID.contains("chrome") { - extra.append(contentsOf: ["googleupdater", "keystone", "googlesoftwareupdate", "chrome"]) - } - - return extra - } + // Propogate from seeds + await graph.propagateFromSeeds(maxDepth: 5) - private func matches(fileName: String, patterns: [String]) -> Bool { - for pattern in patterns { - if fileName.localizedCaseInsensitiveContains(pattern) { - return true - } + // Assess confidence + let nodes = await graph.allNodes() + let assessments = ConfidenceEngine.assessAll(nodes, identity: identity) + var related: [(RelatedFile, ConfidenceTier)] = [] + + for (node, assessment) in zip(nodes, assessments) { + guard assessment.tier != .ignore else { continue } + guard (try? safetyManager.validate(url: node.url)) != nil else { continue } + guard node.url.path != identity.bundleURL.path else { continue } + guard !identity.bundleURL.path.hasPrefix(node.url.path + "/") else { continue } + + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: node.url.path, isDirectory: &isDir) else { continue } + + let fileSize = await getDirectorySize(url: node.url) + let risk: DeletionRisk = node.url.path.contains("Preferences") ? .normal : .safe + let isSelected = true + + let file = RelatedFile( + url: node.url, + isSelected: isSelected, + size: fileSize, + deletionRisk: risk, + evidence: assessment.evidence, + confidence: assessment.tier + ) + related.append((file, assessment.tier)) } - return false + + // Dedup by prefix, sort by tier then path + return dedupAndSort(related) } - private func runMdfind(bundleID: String?, appName: String) async -> Set { - var urls = Set() - let query = bundleID ?? appName - - do { - let result = try await commandRunner.run(command: "/usr/bin/mdfind", arguments: [query]) - let paths = result.stdout.components(separatedBy: .newlines) - for path in paths where !path.isEmpty { - let url = URL(fileURLWithPath: path) - let pathStr = url.path - if pathStr.contains("/Library/") || pathStr.contains("/tmp/") || pathStr.hasPrefix("/private/var/folders/") { - urls.insert(url) + // MARK: - Batch Deep Scan + + public func deepScanAll(apps: [AppInfo]) async -> [AppInfo] { + await withTaskGroup(of: AppInfo?.self, returning: [AppInfo].self) { group in + for app in apps { + group.addTask { + try? await self.deepScan(app) } } - } catch { - Logger.uninstaller.error("mdfind failed: \(error.localizedDescription, privacy: .public)") - } - - return urls - } - - private func getPkgFiles(bundleID: String?) async -> Set { - guard let bundleID = bundleID else { return [] } - let result = try? await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--files", bundleID]) - guard let output = result?.stdout else { return [] } - - var urls = Set() - let lines = output.components(separatedBy: .newlines) - for line in lines where !line.isEmpty { - let path = "/\(line)" - let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path) { - urls.insert(url) + var results: [AppInfo] = [] + for await result in group { + if let result { results.append(result) } } + return results } - return urls } - private func getTeamIdentifier(url: URL) async -> String? { - let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-dv", "--verbose=4", url.path]) - guard let output = result?.stderr else { return nil } - - if let range = output.range(of: "TeamIdentifier=") { - let start = range.upperBound - let end = output[start...].firstIndex(where: { $0.isWhitespace || $0.isNewline }) ?? output.endIndex - return String(output[start.. AppInfo { + try await discoverAndIndex(appURL) } + // MARK: - Uninstall + public func uninstall(app: AppInfo, bypassTrash: Bool = false, emptyTrashImmediately: Bool = false) async throws { Logger.uninstaller.info("Uninstalling '\(app.name, privacy: .public)' bypassTrash=\(bypassTrash)") - // 1. Unload launch agents/daemons + let relatedTargets = app.relatedFiles.filter(\.isSelected).map(\.url) + let devTargets = app.developerComponents.filter(\.isSelected).map(\.url) + let deletionTargets = relatedTargets + devTargets + let snapshot = UninstallSnapshot( + appName: app.name, + bundleID: app.bundleID ?? "unknown", + appVersion: app.version.isEmpty ? nil : app.version, + appBundlePath: app.url.path, + deletedPaths: [app.url.path] + deletionTargets.map(\.path), + bypassTrash: bypassTrash + ) + do { + let store = SnapshotStore(fileManager: .default) + try await store.save(snapshot: snapshot) + Logger.uninstaller.info("Saved uninstall snapshot '\(snapshot.id.uuidString, privacy: .public)'") + } catch { + Logger.uninstaller.warning("Failed to save snapshot: \(error.localizedDescription, privacy: .public)") + } + for file in app.relatedFiles where file.isSelected { let path = file.url.path if (path.contains("LaunchAgents") || path.contains("LaunchDaemons")), path.hasSuffix(".plist") { @@ -791,9 +344,6 @@ public actor UninstallerService { } } - // 2. Move files to trash or remove permanently - let deletionTargets = app.relatedFiles.filter(\.isSelected).map(\.url) - if bypassTrash { try safetyManager.validate(url: app.url) do { @@ -803,7 +353,6 @@ public actor UninstallerService { Logger.uninstaller.error("removeItem failed '\(app.url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") throw error } - for target in deletionTargets { do { try safetyManager.validate(url: target) @@ -821,7 +370,6 @@ public actor UninstallerService { Logger.uninstaller.error("trashItem failed '\(app.url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") throw error } - for target in deletionTargets { do { _ = try await trashManager.trashItem(at: target) @@ -832,7 +380,6 @@ public actor UninstallerService { } } - // 3. Forget package if applicable if let bundleID = app.bundleID { do { _ = try await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--forget", bundleID]) @@ -842,7 +389,6 @@ public actor UninstallerService { } } - // 4. Empty Trash immediately if requested if emptyTrashImmediately { do { try await trashManager.requestTrashAccess() @@ -853,5 +399,64 @@ public actor UninstallerService { } Logger.uninstaller.info("Uninstall complete: '\(app.name, privacy: .public)'") + + // Verification + if let identity = app.identity { + let engine = VerificationEngine( + commandRunner: commandRunner, + codesignCache: codesignCache, + plistCache: plistCache + ) + let report = await engine.verify(identity: identity) + if report.hasLeftovers { + Logger.uninstaller.warning("\(report.count, privacy: .public) leftover(s) after uninstall of '\(app.name, privacy: .public)'") + } else { + Logger.uninstaller.info("0 leftovers — clean uninstall of '\(app.name, privacy: .public)'") + } + } + } + + // MARK: - Private helpers + + private func version(from url: URL) -> String { + Bundle(url: url)?.infoDictionary?["CFBundleShortVersionString"] as? String + ?? Bundle(url: url)?.infoDictionary?["CFBundleVersion"] as? String + ?? "version_unknown".localized + } + + private func getDirectorySize(url: URL) async -> Int64 { + fileManager.getDirectorySize(url: url, excludedPaths: []) + } + + private func mergeApps(_ apps: [AppInfo]) -> [AppInfo] { + var merged: [String: AppInfo] = [:] + for app in apps { + let key = "\(app.bundleID ?? "")-\(app.name)" + if let existing = merged[key] { + var mainApp = existing + if app.url.path.hasPrefix("/Applications") && !existing.url.path.hasPrefix("/Applications") { + mainApp = app + } + merged[key] = mainApp + } else { + merged[key] = app + } + } + return Array(merged.values) + } + + private func dedupAndSort(_ items: [(file: RelatedFile, tier: ConfidenceTier)]) -> [RelatedFile] { + let sortedByPath = items.map(\.file).sorted { $0.url.path.count < $1.url.path.count } + var deduplicated: [URL: RelatedFile] = [:] + for file in sortedByPath { + let isChild = deduplicated.keys.contains { file.url.path.hasPrefix($0.path + "/") } + if !isChild { + deduplicated[file.url] = file + } + } + return Array(deduplicated.values).sorted { lhs, rhs in + if lhs.confidence != rhs.confidence { return lhs.confidence > rhs.confidence } + return lhs.url.path < rhs.url.path + } } } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index f01b2f0..5ffd148 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -16,15 +16,17 @@ struct UninstallerView: View { @State private var searchText = "" @State private var isTargeted = false @State private var showingConfirmation = false - @State private var isExpertMode = false @State private var isLoading = false + @State private var deepScanCache: [URL: UninstallerService.AppInfo] = [:] + @State private var isDeepScanning = false + @State private var deepScanCompleted = 0 + @State private var deepScanTotal = 0 - private let formatter: ByteCountFormatter = { - let f = ByteCountFormatter() + private var formatter: ByteCountFormatter { + let f = ByteCountFormatter.makeLocalized(countStyle: .file) f.allowedUnits = [.useAll] - f.countStyle = .file return f - }() + } var filteredApps: [UninstallerService.AppInfo] { if searchText.isEmpty { @@ -48,11 +50,40 @@ struct UninstallerView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(filteredApps, selection: $selectedApp) { app in - AppRowView(app: app, formatter: formatter, showRelatedFiles: settings.showRelatedFiles) - .tag(app) + 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)) + } + List(filteredApps) { app in + let unscan = app.scanState != .deepScanned + AppRowView( + app: app, + formatter: formatter, + showRelatedFiles: settings.showRelatedFiles, + isUnscannable: unscan + ) + .contentShape(Rectangle()) + .onTapGesture { + guard app.scanState == .deepScanned else { return } + selectedApp = app + } + .listRowBackground( + selectedApp?.url == app.url + ? Color.accentColor.opacity(0.1) + : Color.clear + ) + } + .listStyle(.inset) } - .listStyle(.inset) } } .frame(width: max(250, geometry.size.width * 0.3)) // 30% width but min 250 @@ -84,6 +115,13 @@ struct UninstallerView: View { } } .onAppear(perform: loadApps) + .onChange(of: selectedApp?.url) { oldURL, newURL in + guard let url = newURL else { return } + guard let app = allApps.first(where: { $0.url == url }) else { return } + if app.scanState != .deepScanned { + selectedApp = nil + } + } .confirmationDialog( settings.bypassTrashOnUninstall ? "uninstaller_confirm_perm_delete".localized @@ -104,7 +142,7 @@ struct UninstallerView: View { Button("cancel".localized, role: .cancel) { } } message: { if let app = selectedApp { - let count = app.relatedFiles.filter(\.isSelected).count + let count = app.relatedFiles.filter(\.isSelected).count + app.developerComponents.filter(\.isSelected).count if settings.bypassTrashOnUninstall { Text(String(format: "uninstaller_uninstall_app_warning_perm".localized, app.name, Int64(count))) } else { @@ -117,8 +155,33 @@ struct UninstallerView: View { private func loadApps() { isLoading = true Task { - allApps = (try? await service.scanAllApplications()) ?? [] + let fresh = (try? await service.scanAllApplications()) ?? [] + allApps = fresh isLoading = false + + let total = fresh.count + guard total > 0 else { return } + + isDeepScanning = true + deepScanCompleted = 0 + deepScanTotal = total + + for app in fresh { + if let result = try? await service.deepScan(app) { + deepScanCompleted += 1 + if let idx = allApps.firstIndex(where: { $0.url == result.url }) { + allApps[idx] = result + } + if selectedApp?.url == result.url { + selectedApp = result + } + deepScanCache[result.url] = result + } else { + deepScanCompleted += 1 + } + } + + isDeepScanning = false } } @@ -236,28 +299,10 @@ struct UninstallerView: View { } Divider() - - // Expert Mode Toggle (respect settings.showRelatedFiles & skipExpertMode) + if settings.showRelatedFiles { - if !settings.skipExpertMode { - Toggle(isOn: $isExpertMode.animation()) { - HStack { - Text("uninstaller_expert_mode".localized) - .font(.headline) - Text("uninstaller_select_files".localized) - .font(.caption) - .foregroundColor(.secondary) - } - } - .toggleStyle(.switch) - } - - if isExpertMode && !settings.skipExpertMode { - relatedFilesSection(app) - } else { - simpleFilesSection(app) - } - + relatedFilesSection(app) + if !app.developerComponents.isEmpty { developerComponentsSection(app) } @@ -289,9 +334,9 @@ struct UninstallerView: View { @ViewBuilder private func badges(for app: UninstallerService.AppInfo) -> some View { DetailBadge(title: "version".localized, value: app.version) - DetailBadge(title: "size".localized, value: formatter.string(fromByteCount: settings.showRelatedFiles ? app.totalSize : app.size)) + DetailBadge(title: "size".localized, value: ByteCountFormatter.localizedString(fromByteCount: settings.showRelatedFiles ? app.totalSize : app.size, countStyle: .file)) if let lastUsed = app.lastUsed { - DetailBadge(title: "last_used".localized, value: lastUsed.formatted(.dateTime.year().month().day())) + DetailBadge(title: "last_used".localized, value: lastUsed.formatted(.dateTime.year().month().day().locale(LanguageManager.shared.currentLocale))) } } @@ -318,7 +363,7 @@ struct UninstallerView: View { private func actionButton(for app: UninstallerService.AppInfo) -> some View { let sizeToReclaim = settings.showRelatedFiles ? app.totalSize : app.size return VStack(alignment: .trailing, spacing: 8) { - Text(String(format: "uninstaller_space_reclaim".localized, formatter.string(fromByteCount: sizeToReclaim))) + Text(String(format: "uninstaller_space_reclaim".localized, ByteCountFormatter.localizedString(fromByteCount: sizeToReclaim, countStyle: .file))) .font(.headline) Button(action: { showingConfirmation = true }) { @@ -333,41 +378,67 @@ struct UninstallerView: View { } } - private func simpleFilesSection(_ app: UninstallerService.AppInfo) -> some View { - VStack(alignment: .leading, spacing: 12) { - Label( - String(format: "uninstaller_related_files_count".localized, Int64(app.relatedFiles.count)), - systemImage: "doc.on.doc" - ) - .font(.headline) - - Text("uninstaller_expert_tip".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - private func relatedFilesSection(_ app: UninstallerService.AppInfo) -> some View { - VStack(alignment: .leading, spacing: 12) { - if !app.relatedFiles.isEmpty { - Label("uninstaller_cleanup_items".localized, systemImage: "list.bullet.indent") - .font(.headline) - - VStack(spacing: 1) { - ForEach(app.relatedFiles) { file in - RelatedFileRow(file: file, formatter: formatter) { - toggleSelection(file, in: app) + let grouped = Dictionary(grouping: app.relatedFiles) { $0.confidence } + let allTiers = ConfidenceTier.allCases.filter { $0 != .ignore }.sorted(by: >) + let visibleTiers = allTiers + let selectedCount = app.relatedFiles.filter(\.isSelected).count + return VStack(alignment: .leading, spacing: 12) { + ForEach(visibleTiers, id: \.self) { tier in + let files = grouped[tier] ?? [] + if !files.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label(tier.displayKey.localized, systemImage: tierIcon(tier)) + .font(.subheadline) + .foregroundColor(tierColor(tier)) + + VStack(spacing: 1) { + ForEach(files) { file in + RelatedFileRow( + file: file, + formatter: formatter, + onToggle: { toggleSelection(file, in: app) } + ) + } } + .background(Color(NSColor.alternatingContentBackgroundColors[0])) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) } } - .background(Color(NSColor.alternatingContentBackgroundColors[0])) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) } + if selectedCount > 0 { + let tierLabels = allTiers.compactMap { t -> String? in + let count = grouped[t]?.count ?? 0 + guard count > 0 else { return nil } + return "\(count) \(t.displayKey.localized)" + }.joined(separator: ", ") + Text(String(format: "uninstaller.footer.summary".localized, Int64(selectedCount), tierLabels)) + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + } + } + } + + private func tierIcon(_ tier: ConfidenceTier) -> 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" + } + } + private func tierColor(_ tier: ConfidenceTier) -> Color { + switch tier { + case .guaranteed: return .green + case .veryLikely: return .blue + case .possible: return .orange + case .ignore: return .gray } } @@ -376,8 +447,16 @@ struct UninstallerView: View { Label("uninstaller_developer_components".localized, systemImage: "wrench.adjustable") .font(.headline) - ForEach(app.developerComponents) { component in + ForEach(Array(app.developerComponents.enumerated()), id: \.element.id) { index, component in HStack { + Toggle("", isOn: Binding( + get: { component.isSelected }, + set: { newValue in + toggleDeveloperComponent(in: app, at: index, value: newValue) + } + )) + .toggleStyle(.checkbox) + Image(systemName: "shippingbox") .foregroundColor(.purple) .font(.subheadline) @@ -386,14 +465,14 @@ struct UninstallerView: View { Text(component.title) .font(.subheadline) .fontWeight(.medium) - Text(component.category.rawValue) + Text(component.category.localizedTitle) .font(.caption2) .foregroundColor(.secondary) } Spacer() - Text(formatter.string(fromByteCount: component.sizeBytes)) + Text(ByteCountFormatter.localizedString(fromByteCount: component.sizeBytes, countStyle: .file)) .font(.subheadline) .foregroundColor(.secondary) } @@ -431,13 +510,23 @@ struct UninstallerView: View { } } } + + private func toggleDeveloperComponent(in app: UninstallerService.AppInfo, at index: Int, value: Bool) { + if let appIndex = allApps.firstIndex(where: { $0.id == app.id }) { + allApps[appIndex].developerComponents[index].isSelected = value + if selectedApp?.id == app.id { + selectedApp = allApps[appIndex] + } + } + } } struct AppRowView: View { let app: UninstallerService.AppInfo let formatter: ByteCountFormatter let showRelatedFiles: Bool - + let isUnscannable: Bool + var body: some View { HStack(spacing: 12) { if let iconData = app.iconData, let nsImage = NSImage(data: iconData) { @@ -449,17 +538,24 @@ struct AppRowView: View { .fill(Color.secondary.opacity(0.2)) .frame(width: 32, height: 32) } - + VStack(alignment: .leading, spacing: 2) { Text(app.name) .font(.body) .fontWeight(.medium) - Text(formatter.string(fromByteCount: showRelatedFiles ? app.totalSize : app.size)) - .font(.caption) - .foregroundColor(.secondary) + if isUnscannable { + Text("uninstaller.analyzing".localized) + .font(.caption) + .foregroundColor(.secondary.opacity(0.5)) + } else { + Text(ByteCountFormatter.localizedString(fromByteCount: showRelatedFiles ? app.totalSize : app.size, countStyle: .file)) + .font(.caption) + .foregroundColor(.secondary) + } } } .padding(.vertical, 4) + .opacity(isUnscannable ? 0.5 : 1.0) } } @@ -518,7 +614,16 @@ struct RelatedFileRow: View { Spacer() - Text(formatter.string(fromByteCount: file.size)) + Button { + NSWorkspace.shared.activateFileViewerSelecting([file.url]) + } label: { + Image(systemName: "arrow.up.forward.app") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("uninstaller_show_in_finder".localized) + + Text(ByteCountFormatter.localizedString(fromByteCount: file.size, countStyle: .file)) .font(.caption) .foregroundColor(.secondary) } diff --git a/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift new file mode 100644 index 0000000..b0faf42 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift @@ -0,0 +1,74 @@ +import Foundation +import OSLog + +private extension Logger { + static let verification = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "VerificationEngine") +} + +public actor VerificationEngine { + private let commandRunner: any CommandRunning + private let codesignCache: CodesignCache + private let plistCache: PlistContentCache + private let thresholds: ScoreThresholds + private let weights: ScoringWeights + + public init( + commandRunner: any CommandRunning = CommandRunner(), + codesignCache: CodesignCache = CodesignCache(), + plistCache: PlistContentCache = PlistContentCache(), + thresholds: ScoreThresholds = .default, + weights: ScoringWeights = .default + ) { + self.commandRunner = commandRunner + self.codesignCache = codesignCache + self.plistCache = plistCache + self.thresholds = thresholds + self.weights = weights + } + + public func verify(identity: AppIdentity) async -> VerificationReport { + Logger.verification.info("Verifying '\(identity.appName, privacy: .public)' after uninstall") + + let collector = CandidateCollector(fileManager: .default, commandRunner: commandRunner) + let candidates = await collector.collect(identity: identity) + + guard !candidates.isEmpty else { + Logger.verification.info("0 leftovers found") + return VerificationReport(leftovers: []) + } + + let probe = EvidenceProbe( + commandRunner: commandRunner, + codesignCache: codesignCache, + plistCache: plistCache + ) + + var artifacts: [ScoredArtifact] = [] + + for url in candidates { + let evidence = await probe.probe(url: url, identity: identity) + guard !evidence.isEmpty else { continue } + + let artifactEvidence = evidence.artifactEvidence(weights: weights) + let score = artifactEvidence.reduce(0) { $0 + $1.weight } + + artifacts.append( + ScoredArtifact(url: url, score: score, evidence: artifactEvidence) + ) + } + + let classified = ArtifactClassifier.classifyBatch(artifacts, thresholds: thresholds) + + var leftovers: [ScoredArtifact] = [] + leftovers.append(contentsOf: classified.related.map(\.artifact)) + leftovers.append(contentsOf: classified.developer.map(\.artifact)) + + let report = VerificationReport(leftovers: leftovers) + 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)") + } + + return report + } +} diff --git a/MacOSCleaner/Features/Uninstaller/VerificationReport.swift b/MacOSCleaner/Features/Uninstaller/VerificationReport.swift new file mode 100644 index 0000000..13a6258 --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/VerificationReport.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct VerificationReport: Sendable { + public let leftovers: [ScoredArtifact] + public let count: Int + + public var hasLeftovers: Bool { count > 0 } + + public init(leftovers: [ScoredArtifact]) { + self.leftovers = leftovers + self.count = leftovers.count + } +} diff --git a/MacOSCleaner/Infrastructure/FileCleanupActor.swift b/MacOSCleaner/Infrastructure/FileCleanupActor.swift index 687811b..459788d 100644 --- a/MacOSCleaner/Infrastructure/FileCleanupActor.swift +++ b/MacOSCleaner/Infrastructure/FileCleanupActor.swift @@ -222,10 +222,10 @@ public actor FileCleanupActor { } static func formatBytes(_ bytes: Int64) -> String { - if bytes < 1024 { return "\(bytes) B" } - if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } - if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) } - return String(format: "%.2f GB", Double(bytes) / (1024 * 1024 * 1024)) + if bytes < 1024 { return String(format: "format_bytes_b".localized, bytes) } + if bytes < 1024 * 1024 { return String(format: "format_bytes_kb".localized, Double(bytes) / 1024) } + if bytes < 1024 * 1024 * 1024 { return String(format: "format_bytes_mb".localized, Double(bytes) / (1024 * 1024)) } + return String(format: "format_bytes_gb".localized, Double(bytes) / (1024 * 1024 * 1024)) } static func shortPath(_ path: String) -> String { diff --git a/MacOSCleaner/Infrastructure/LanguageManager.swift b/MacOSCleaner/Infrastructure/LanguageManager.swift index f474e98..05f6c50 100644 --- a/MacOSCleaner/Infrastructure/LanguageManager.swift +++ b/MacOSCleaner/Infrastructure/LanguageManager.swift @@ -5,6 +5,17 @@ public final class LanguageManager: @unchecked Sendable { private let bundleLock = NSLock() private var _bundle: Bundle = .main + private var _currentLanguage: AppLanguage = .english + + public var currentLanguage: AppLanguage { + bundleLock.lock() + defer { bundleLock.unlock() } + return _currentLanguage + } + + public var currentLocale: Locale { + currentLanguage.locale + } private var bundle: Bundle { bundleLock.lock() @@ -13,13 +24,17 @@ public final class LanguageManager: @unchecked Sendable { } private init() { - // По умолчанию инициализируем английским или сохраненным let savedLang = UserDefaults.standard.string(forKey: "settings_language") ?? "en" - updateBundle(for: savedLang) + let lang = AppLanguage(rawValue: savedLang) ?? .english + _currentLanguage = lang + updateBundle(for: lang.rawValue) } public func setLanguage(_ language: AppLanguage) { updateBundle(for: language.rawValue) + bundleLock.lock() + _currentLanguage = language + bundleLock.unlock() } private func updateBundle(for langCode: String) { @@ -38,3 +53,14 @@ public final class LanguageManager: @unchecked Sendable { return NSLocalizedString(key, bundle: bundle, comment: "") } } + +extension AppLanguage { + var locale: Locale { + switch self { + case .english: return Locale(identifier: "en_US") + case .russian: return Locale(identifier: "ru_RU") + case .ukrainian: return Locale(identifier: "uk_UA") + case .spanish: return Locale(identifier: "es_ES") + } + } +} diff --git a/MacOSCleaner/Infrastructure/PermissionsManager.swift b/MacOSCleaner/Infrastructure/PermissionsManager.swift index e8dd1c1..cc6f2d0 100644 --- a/MacOSCleaner/Infrastructure/PermissionsManager.swift +++ b/MacOSCleaner/Infrastructure/PermissionsManager.swift @@ -41,33 +41,42 @@ public final class PermissionsManager { /// Checks if the application has Full Disk Access by attempting to read protected paths. public static func checkFullDiskAccess() -> Bool { let fm = FileManager.default - let home = NSHomeDirectory() - let testPaths = [ - "/Library/Application Support", - home + "/Library/Caches", - home + "/Library/Application Support", - home + "/Library/Mail", - home + "/Library/Messages", - home + "/Library/Safari", - home + "/Library/Keychains", - home + "/Library/Calendars", - home + "/Library/Contacts", + // Directories with restricted permissions — listing contents requires FDA + let protectedPaths = [ + "/Library/Application Support/com.apple.TCC", + "/private/var/db/dslocal", ] - var failedPaths: [String] = [] - for path in testPaths { + var checked = false + for path in protectedPaths { guard fm.fileExists(atPath: path) else { continue } + checked = true do { _ = try fm.contentsOfDirectory(atPath: path) } catch { - failedPaths.append(path) + Logger.permissions.warning("FDA check failed at: \(path)") + return false } } - if !failedPaths.isEmpty { - Logger.permissions.warning("FDA check failed for: \(failedPaths.joined(separator: ", "))") - return false + // Fallback for older macOS — try Keychains with attribute check + if !checked { + let keychains = "/Library/Keychains" + if fm.fileExists(atPath: keychains) { + do { + // attributesOfItem requires read access to the item metadata, + // which is a stronger check than listing parent directory + _ = try fm.attributesOfItem(atPath: keychains) + if let items = try? fm.contentsOfDirectory(atPath: keychains), + let first = items.first { + _ = try fm.attributesOfItem(atPath: keychains + "/" + first) + } + } catch { + Logger.permissions.warning("FDA check failed at: \(keychains)") + return false + } + } } Logger.permissions.info("Full Disk Access check passed") @@ -117,16 +126,16 @@ public final class PermissionsManager { public var missingPermissions: [String] { var missing: [String] = [] if !hasFullDiskAccess { - missing.append("Full Disk Access") + missing.append("permissions.full_disk_access".localized) } if !hasAccessibility { - missing.append("Accessibility") + missing.append("permissions.accessibility".localized) } if !hasAutomation { - missing.append("Automation (Apple Events)") + missing.append("permissions.automation".localized) } if !hasTrashAccess { - missing.append("Trash Access") + missing.append("permissions.trash_access".localized) } return missing } diff --git a/MacOSCleaner/Infrastructure/String+Localization.swift b/MacOSCleaner/Infrastructure/String+Localization.swift index 421e7fb..32bf181 100644 --- a/MacOSCleaner/Infrastructure/String+Localization.swift +++ b/MacOSCleaner/Infrastructure/String+Localization.swift @@ -9,3 +9,40 @@ public extension String { return String(format: self.localized, arguments: arguments) } } + +public extension ByteCountFormatter { + static func localizedString(fromByteCount count: Int64, countStyle: ByteCountFormatter.CountStyle) -> String { + let style: ByteCountFormatStyle.Style = (countStyle == .memory) ? .memory : .file + return count.formatted(.byteCount(style: style).locale(LanguageManager.shared.currentLocale)) + } + + static func makeLocalized(countStyle: ByteCountFormatter.CountStyle = .file) -> ByteCountFormatter { + let f = ByteCountFormatter() + f.countStyle = countStyle + return f + } +} + +public extension Int64 { + func formattedByteCount(style: ByteCountFormatStyle.Style = .file) -> String { + let locale = LanguageManager.shared.currentLocale + return self.formatted(.byteCount(style: style).locale(locale)) + } +} + +public extension DateFormatter { + static func makeLocalized(dateStyle: DateFormatter.Style = .medium, timeStyle: DateFormatter.Style = .none) -> DateFormatter { + let f = DateFormatter() + f.dateStyle = dateStyle + f.timeStyle = timeStyle + f.locale = LanguageManager.shared.currentLocale + return f + } + + static func makeLocalized(withFormat dateFormat: String) -> DateFormatter { + let f = DateFormatter() + f.dateFormat = dateFormat + f.locale = LanguageManager.shared.currentLocale + return f + } +} diff --git a/MacOSCleaner/Infrastructure/SystemInfo.swift b/MacOSCleaner/Infrastructure/SystemInfo.swift index cb8c86a..37081f0 100644 --- a/MacOSCleaner/Infrastructure/SystemInfo.swift +++ b/MacOSCleaner/Infrastructure/SystemInfo.swift @@ -8,10 +8,10 @@ public struct SystemInfo: Sendable { public static var current: SystemInfo { let os = ProcessInfo.processInfo.operatingSystemVersion - let osString = "macOS \(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" + let osString = String(format: "os_version_format".localized, os.majorVersion, os.minorVersion, os.patchVersion) let memoryBytes = ProcessInfo.processInfo.physicalMemory - let memoryString = ByteCountFormatter.string(fromByteCount: Int64(memoryBytes), countStyle: .memory) + let memoryString = ByteCountFormatter.localizedString(fromByteCount: Int64(memoryBytes), countStyle: .memory) return SystemInfo( model: getModelIdentifier(), diff --git a/MacOSCleaner/Infrastructure/TrashManager.swift b/MacOSCleaner/Infrastructure/TrashManager.swift index f2e5a50..6f73336 100644 --- a/MacOSCleaner/Infrastructure/TrashManager.swift +++ b/MacOSCleaner/Infrastructure/TrashManager.swift @@ -93,8 +93,8 @@ public actor TrashManager { try await MainActor.run { let panel = NSOpenPanel() - panel.message = NSLocalizedString("Please select the Trash folder to grant access for scanning and cleaning. (It is already selected, just click 'Grant Access')", comment: "") - panel.prompt = NSLocalizedString("Grant Access", comment: "") + panel.message = "trash_access_prompt_message".localized + panel.prompt = "trash_access_prompt_button".localized panel.directoryURL = trashURL panel.canChooseDirectories = true panel.canChooseFiles = false diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index 8176b6c..e69fdf3 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -7,82 +7,178 @@ objects = { /* Begin PBXBuildFile section */ + 0025FDCCBE620B27A88A17A1 /* ParentLinker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C2514ADFBB5E6A83A2FB55 /* ParentLinker.swift */; }; + 0034381EEDFEBD9342599AB4 /* EmbeddedCleanupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0161725DC48B94D957228EB3 /* EmbeddedCleanupPaths.swift */; }; 00CF78722CECD33C0634541C /* TrashManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C5FD8CA29FB9E59A400223 /* TrashManager.swift */; }; + 01FE9E2C2B37A96C1F4A8B00 /* ApplicationRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E17B97D1E3EEB28B3DDD0E /* ApplicationRule.swift */; }; + 03FDBD7F79B43C582A6C0EC5 /* ConfidenceEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA64165D2FBAC03ACE56DC6 /* ConfidenceEngineTests.swift */; }; + 0428CB5B74191FAFF78C6F48 /* LittleSnitchRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6439AA19BF8E10021E213015 /* LittleSnitchRule.swift */; }; + 0626FC3A62573329D86A06D0 /* CleanupPathProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745899F2BC71D0F92EDC35A2 /* CleanupPathProvider.swift */; }; + 07337BBA7A5B8BEB5557019A /* RealWorldValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230649540105FBDF7CE4FDF6 /* RealWorldValidationTests.swift */; }; + 088A8B56F3BCCD74A74508A0 /* Cursor.json in Resources */ = {isa = PBXBuildFile; fileRef = 086A1B781AA3F6ACC5AFA252 /* Cursor.json */; }; 09824927A11CC992B106D4CD /* ProcessInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A116D669586B4CCC7108FC /* ProcessInfoProvider.swift */; }; 0C6462E222FBB78EEDB1E781 /* CleanupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F3794501798DF583436B09 /* CleanupEngine.swift */; }; + 0D71B75F7521BAECEAEF7119 /* DockerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8AC033173B97C266D97DF2 /* DockerRule.swift */; }; 0EC337EF82F7F025681B3725 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 725A7CDFB17CB2FC1AC59560 /* Assets.xcassets */; }; + 11A7D2871426F39EFA6D740B /* EvidenceProbeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED4199D09AC66B4B220BDC8 /* EvidenceProbeTests.swift */; }; 162CB83D7194D6BD718593A0 /* CommandRunning.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BB7DDAA6BEB78C47B1EAD3 /* CommandRunning.swift */; }; + 1665896332214762C62ED272 /* EpicGames.json in Resources */ = {isa = PBXBuildFile; fileRef = 26A12A1159CFA31A969B390C /* EpicGames.json */; }; 1BC98E1954D1AFBBCC49050D /* CleanupStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BDBFF77A67A9141C1C5796 /* CleanupStateMachineTests.swift */; }; 1BE271D883FB9BB89236DA15 /* OperationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F9733A8855228C1AC3476B /* OperationRecord.swift */; }; 1BF512D726CDDC74358EF3B2 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3245FE8F18E51D3347743E37 /* PermissionsView.swift */; }; + 1CA1BC5393BE23A5F8B3F20B /* ScoringWeights.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */; }; 2005604ECBBC2DF0D8C637F0 /* UninstallerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FC1AE8DD7419C6804C170D /* UninstallerView.swift */; }; + 20777F77163CF80A0DACE3F2 /* LittleSnitch.json in Resources */ = {isa = PBXBuildFile; fileRef = EFB823E397459E29F026EFB6 /* LittleSnitch.json */; }; 2282CB50ADC015170FFA009C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1587E61B0489D075EE10BA3D /* AppSettings.swift */; }; + 24BC2B8C0ADE2ECE5177F6CB /* ApplicationRuleRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586694A909DE65B53CC8EDA0 /* ApplicationRuleRegistryTests.swift */; }; + 2994E7C0CD0AB5AA8E3FD4D8 /* Evidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D051655B09FA196A2BD952F2 /* Evidence.swift */; }; 29F1245AEC8E7426A89D8597 /* CleanupNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71786FFCFB9821335C9B9E8 /* CleanupNotifier.swift */; }; + 2AE8B02C185575DD27F1BF83 /* ArtifactClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906E6511D36A808F61A68A15 /* ArtifactClassifierTests.swift */; }; 2C15EA5886E20334BA912869 /* RetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 395231352EDCF3A07221C2FB /* RetryPolicy.swift */; }; 2D5855CF50F6DF6E37046E84 /* CodeSignatureInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26CC1A76E88A0BBF3B47E5A /* CodeSignatureInfo.swift */; }; + 2D792A2983E94EDE263F97BC /* VerificationReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCE01589664A4F3C4EA3182 /* VerificationReport.swift */; }; + 2D9E139399502E0900C1DA6F /* LSRegisterCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670B4B2D58E43D6491C71D88 /* LSRegisterCacheTests.swift */; }; + 2E38FD4A6C11C4E5E11FF511 /* TerminalRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7479AAE9D83844B12D74B02 /* TerminalRule.swift */; }; + 2EFB638B382F68A4A21CD7AB /* EvidenceExplanationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF2C09A741A9F06D78858A0C /* EvidenceExplanationTests.swift */; }; + 2FBBF85D96918E09A80C5939 /* DeveloperComponentsDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD2376BA6F50A27A67E4736 /* DeveloperComponentsDetector.swift */; }; + 31B9F1688720FBE10F8F3B46 /* MicrosoftOffice.json in Resources */ = {isa = PBXBuildFile; fileRef = D1E2968239F9E780D1928B58 /* MicrosoftOffice.json */; }; + 32D15D3B2B3404DE9662CEA5 /* EvidenceCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */; }; + 338946DFC3F61C72FCC52D5E /* AdobeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D50E5702E806D30909278 /* AdobeRule.swift */; }; + 36CFC6F9A71EE6AA66E178C5 /* LaunchctlCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B625D7C04F6EC419FA5F0764 /* LaunchctlCache.swift */; }; 370555A1E46A82B05D6374B0 /* CleanupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16400AE1862B76A844B01CFC /* CleanupView.swift */; }; 3922CCC01F73A38F68A94C2A /* PosixScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F514077EE94AA6AF042FE5B0 /* PosixScanner.swift */; }; 3A9F6B68130F3DAF5916234B /* AppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8526E6E523335A441DE17858 /* AppSettingsTests.swift */; }; + 4117A3A7BD3835699732D21D /* MdfindCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9763DE94720F8AF4B9C0AE3 /* MdfindCache.swift */; }; + 4189AEA27F9A79D1E00F7157 /* ArtifactClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F9B22B36D401D28509CF70 /* ArtifactClassifier.swift */; }; + 42366FBE09FE46182F73C86C /* VerificationEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */; }; + 4249D6B3C9C4B39B14606220 /* GitClientsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41CA662F940D168EC8F30295 /* GitClientsRule.swift */; }; + 472B1E10D4A7B420B64CADD2 /* CleanupCategory+FixtureMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7117987756095224817906 /* CleanupCategory+FixtureMapping.swift */; }; 4BC8DDBA6B51777F46BC4BA0 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1DD590DCE0D34A3129D242 /* CommandRunner.swift */; }; + 4C9D979A3DEB7D4F0CD486FA /* EvidenceGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D16F75C43CE1DD60ADDFE8E /* EvidenceGraph.swift */; }; 4E95B67B22946FA1B8D60565 /* StartupServicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12280699D34818A47785336 /* StartupServicesView.swift */; }; 4EB7F53ABB85EDB76E342E2B /* ProcessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C7373D2284E648D835B122 /* ProcessManager.swift */; }; 4F36E18C78A15EADBF60C039 /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEF9CB2BE762A51083464F /* FileManager+Size.swift */; }; + 513AE7B833D9E160B63C5761 /* ProbeCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = F107148C0A34FAB65F2D6093 /* ProbeCaches.swift */; }; 51555D0F365275801D2D72F6 /* UninstallerServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8008E88C0D03BB15C65998D /* UninstallerServiceTests.swift */; }; 546A45B9B4E4879BAD95CB97 /* CleanupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBBE8FE6EC8F248C9C4E6B3 /* CleanupEngineTests.swift */; }; 5665E32E91F5C6C213D3AAEA /* CleanupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9A4348B3774A855AC83B1B /* CleanupViewModel.swift */; }; + 56FB37504DA9E64D73AD4B3A /* DatabaseToolsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E52499AE453EB667A3BE9 /* DatabaseToolsRule.swift */; }; 5805C9625FA33C2762194266 /* MockCommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F77DB5F928748036C709D4 /* MockCommandRunner.swift */; }; + 597C46E020148F9810D9763B /* FinalCutProRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB8F9D655F658EFAE5FCA3 /* FinalCutProRule.swift */; }; 5A80FF0AAC67C55F53950A6A /* OperationRisk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C739CAEB68AC6D0D4EF63BD /* OperationRisk.swift */; }; 5D2C3AC6D520ED512CFDE893 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4036BE6AD403CC0E552B386A /* AboutView.swift */; }; 60B681FF2018B69AE6F42A8E /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E3A139737CC8A7ECBD1900 /* PermissionsManager.swift */; }; + 60D5F2F84783437BEABB9834 /* PlistContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE15C2571A58D245F5A01C1 /* PlistContentCache.swift */; }; + 622E4977F3A1981F681C3D8B /* XcodeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB05B780E52D2EA5A76D570 /* XcodeRule.swift */; }; 624F501955DDCE47F965A24C /* FileScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */; }; + 63A1AF79D3508862AED5B581 /* DefaultRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68475F1254EF60843A610CAC /* DefaultRule.swift */; }; + 66773DCDEB6DDD828C28ECB3 /* PlistAnalyzerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D694D1ECD87105CC67BA55C1 /* PlistAnalyzerTests.swift */; }; + 6815BD9B08943A3B4F2F4FF6 /* BaselineFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C92C74A3F932664D5001998 /* BaselineFixture.swift */; }; + 6A91F8F5E97E9DC385D90906 /* SteamRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBD05CD906BFA50A593DAA2 /* SteamRule.swift */; }; 6B0C4DB9DEA1CEE0A40CE7B9 /* StartupVendorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29CC70A6B8DA27FD15BB199 /* StartupVendorSettingsView.swift */; }; 713C1D45A6A1B3350413D996 /* FileCleanupActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD1414A4103E729B6946047 /* FileCleanupActor.swift */; }; + 726D2AB56BC7F27C7BA1332B /* ProblematicAppsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E198DF7B9109A1D7D13338 /* ProblematicAppsTests.swift */; }; 73B0F634314F9E8EE2D4C768 /* DirectorySizeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F806B7312BFC1297D8E172 /* DirectorySizeCache.swift */; }; 775D5D2C093A529875939FC9 /* CommandRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */; }; + 77C8FDB4576E9419A7ACE1E6 /* CandidateCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E94A572C930CB883AFC67CB5 /* CandidateCollector.swift */; }; + 7C4418AE4F14E22136AA7514 /* UnityRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE06D2D275499CE3A8A513EF /* UnityRule.swift */; }; 7C59F9EB60B6D24E70FCFB2D /* FileScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A11C06D6D12353C59E09EF /* FileScanner.swift */; }; + 7D40740FE6271009DC9CE325 /* AppDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */; }; + 7DEF61AD385AC14996379126 /* LSRegisterCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E2BF3CAC7C35E8A39F8E50 /* LSRegisterCache.swift */; }; + 7E95E7129D7B44DCFE10E2C6 /* JetBrainsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA3A02A464DC643AB220B58 /* JetBrainsRule.swift */; }; 7F6B179C933B447BD9F76FCA /* ProcessesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB26137A4BEE8E9B97A374F /* ProcessesViewModel.swift */; }; 7F80CDAE8621DF99A473AB3B /* CleanupStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */; }; + 81622E787C1700C4F73D32CB /* BrowserRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF63D60CE321A50740AB3 /* BrowserRule.swift */; }; + 87EDEF516A814FC99DCA0B31 /* BackgroundItemsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C3847C35AB0FA0E8C80210 /* BackgroundItemsReader.swift */; }; 8E3DECCD5B86A373058DED54 /* StartupServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0086F9F7800BFD540E5F25E /* StartupServicesViewModel.swift */; }; 8F1C2ACF00DF4A34A6CA9862 /* AnimatedScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6703835CE5100161449E56 /* AnimatedScanView.swift */; }; + 92E0F73F02E1797F191A0BCD /* RaycastRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB502AA9A1CC1FB93686DC00 /* RaycastRule.swift */; }; 939FA61E08F828EA3558E544 /* CleanupItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C448F6372606D03FF817C58B /* CleanupItem.swift */; }; + 93A30220DD22B84B9473DB06 /* ParallelsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2BA150F1B8BFD841175AC8 /* ParallelsRule.swift */; }; 9655F1D4887A095D052C9271 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5EFE9DE1E343AF8F2490C67 /* DashboardViewModel.swift */; }; 977AD64777E29D8DE44620DD /* DashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1208E5D13CB1494773BCD1C7 /* DashboardViewModelTests.swift */; }; + 98DBE81A5E6044C50CC228E8 /* SnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C213F7BAD2CB83289015B67 /* SnapshotStoreTests.swift */; }; + 98E6580503F96F10F73A07C3 /* HomebrewRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F374BB86457C4BC4BDC6426 /* HomebrewRule.swift */; }; 9A045237ED42AC3CC1C06031 /* LanguageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08402BF5E1C6101310B2B476 /* LanguageManager.swift */; }; + 9A36A6F47591CFDC0161C465 /* AdobeCC.json in Resources */ = {isa = PBXBuildFile; fileRef = 9BD4987C83B993EB576EB7F2 /* AdobeCC.json */; }; + 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 */; }; + 9EC173288EFFC99A7B34B3A7 /* DashboardView.swift.back in Resources */ = {isa = PBXBuildFile; fileRef = DB8F5E087C85389E6C6C2B26 /* DashboardView.swift.back */; }; 9F48B8272BC8F88521B69141 /* ScanActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1342D347B34E14E2DB3428 /* ScanActor.swift */; }; A50B617B0D6B7F32B53C879A /* ProcessGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12EE41C2EEF2151D9A20EAC7 /* ProcessGroup.swift */; }; A5582FB3CC44494C2D0C5863 /* StartupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD1B0FF19F3B77B06378FAD /* StartupService.swift */; }; + A579926287D201B899842A4D /* IdentityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DFE21E3297B6099B185C4 /* IdentityCache.swift */; }; A663BEC17B22AFC47D7098AC /* RunningProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DBFF8A61FF1E6AB77A7B98 /* RunningProcess.swift */; }; + A771995E76D2909084B5E292 /* KarabinerElementsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB19DF68D3A880EDC0DE961F /* KarabinerElementsRule.swift */; }; + A7DBDF61A41B25A92649D9E2 /* NetworkExtensionRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D24E49A24ED4E353A4D6345 /* NetworkExtensionRule.swift */; }; + AB238D2B717AED4D59708DD7 /* Arc.json in Resources */ = {isa = PBXBuildFile; fileRef = DA8B28911DC4508EB90A34A5 /* Arc.json */; }; AB62F80D9F4D205F5DFE4851 /* CleanupIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54ECA599C22CEE8383F2077D /* CleanupIntegrationTests.swift */; }; + ACA52C8CF526BCFFC6B5824C /* EvidenceProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = C950BCC1062A364886439405 /* EvidenceProbe.swift */; }; ADE0E2089B2A00FB56D8B068 /* CleanupItemManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B55A53EA10B7C9A1308221 /* CleanupItemManager.swift */; }; + AEA91CD04EACACE4AB9C75B6 /* AppIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BC06C9BBE41099A9D54344 /* AppIdentity.swift */; }; AEBD6F435B4BC89EB6A27021 /* ProcessSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AFDCF6F0B36A4DCBEB9BC9 /* ProcessSafetyPolicy.swift */; }; AEEE3F61B192C14B2BA9741D /* ScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */; }; B17DA1284BD059BAA16189CF /* TransactionJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 753F374AA91758652C409A39 /* TransactionJournal.swift */; }; + B262FF81F64BA96FB492B9FD /* Postman.json in Resources */ = {isa = PBXBuildFile; fileRef = 2013004E1BB0E55214A2CC26 /* Postman.json */; }; + B31A42615B4637F2D8C2F303 /* BackgroundItemsReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA0CF16A34242F0F8DAD420 /* BackgroundItemsReaderTests.swift */; }; + B3519A02A233AC4C5D343D1A /* EvidenceGraphTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D4AA64672B844EB923DFF3 /* EvidenceGraphTests.swift */; }; B74924CC7A66323AB8CD2E28 /* CleanupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC107E0D03F39A8293BD8FE4 /* CleanupCoordinator.swift */; }; + BA45213B9E4C9FC0430EBC68 /* DaVinciResolveRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFF580DF2A229E8922F2A19 /* DaVinciResolveRule.swift */; }; BB644D53B3367954A53BA10F /* NavigationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B421C9DC6196E3977508E1CD /* NavigationItem.swift */; }; + BC2AB3D6971549EBF359F15C /* Steam.json in Resources */ = {isa = PBXBuildFile; fileRef = 03DBC8288446E7C56A7DFD28 /* Steam.json */; }; BC7D6B5A297C83B50F760387 /* LaunchServiceManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */; }; BC7F5459F452CC98A955FAEB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A917B441A9E588D76A1B96E /* SettingsView.swift */; }; + BD2107BAF6691B18A67B1297 /* MicrosoftOfficeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AC424333F1C8F21BE5E13A /* MicrosoftOfficeRule.swift */; }; BE3FBA22FA22D8E3A69F5A34 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */; }; + C11A27392992E4545E99913A /* BackgroundItemsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2204402B7AA52282DCCA42CA /* BackgroundItemsCache.swift */; }; + C29BE4B2467ED58C79E26C05 /* EvidenceSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */; }; + C4B3338F6671FDDBA46A9C15 /* CloudStorageRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B6468D52060304776E6A48 /* CloudStorageRule.swift */; }; + C50AEB6F8125492F79A73313 /* ElectronRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D976632F4535BD8561654197 /* ElectronRule.swift */; }; C54E43E230C79CFDE5EAD04C /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6BA430DBA22418A7A0D0D8 /* String+Localization.swift */; }; C89630797CDD60C50CC75BA7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C4ABAEBA90F62DB2DE45641C /* Localizable.strings */; }; C948FD249B6C8437D5BFC872 /* ProcessRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53482F256457BB8CB422EB4 /* ProcessRow.swift */; }; C952B1E49F2B979D4ECE8F04 /* TransactionJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB6DE85A7D910E1F0424CEF /* TransactionJournalTests.swift */; }; CCDEB1A2EE6B7006D95A7ADC /* SafetyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA68BEDE6F540E3477849FC /* SafetyManager.swift */; }; + CF451E1D986FDEB59A0B0F05 /* Confidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61798A3E8B34E827E963D77 /* Confidence.swift */; }; + CFA170D8C84D477F84A53303 /* AppDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DA9FDB4A034B4100A2AFB /* AppDiscovery.swift */; }; D04C2D4589784A88C09C146A /* ProcessCleanupActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA3478B28127BDE2AAFCB8C /* ProcessCleanupActor.swift */; }; D5274B5FCD758749A4974B7C /* CleanupTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D71F05F8569A43F8BF66A8 /* CleanupTransaction.swift */; }; D5736DBAF67A7BA78B39290F /* MacOSCleanerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90BF90F2E933654B06A18BA4 /* MacOSCleanerApp.swift */; }; + D5883737207237A630360049 /* EvidenceExplanation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3858BEEB1516C71EB65033 /* EvidenceExplanation.swift */; }; + D58AAD770F518A3CF40ABC8B /* PlistAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C145D57FE94451F56F45C7DD /* PlistAnalyzer.swift */; }; D5EB7139957B403064AFD099 /* RetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA2BC762F643159A5EE7AA7 /* RetryPolicyTests.swift */; }; + D664F8B31004BF2B0D3B1560 /* NordVPN.json in Resources */ = {isa = PBXBuildFile; fileRef = DD4DB6F7DA427DA269274700 /* NordVPN.json */; }; + D768D4102DD98AD91B8F4EEA /* CandidateCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1797889E54A02B080F3BE6 /* CandidateCollectorTests.swift */; }; DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5A3FFB9EF8E6D7D62FD68D /* LaunchServiceManager.swift */; }; DE4F65F07814A5139023D54F /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180E53446953349EB5FB2E04 /* SystemInfo.swift */; }; DFBF63D178857930319DBB9B /* SafetyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36158099B9A85FFD8B5536BA /* SafetyManagerTests.swift */; }; E0DD3AB3994894965CF333DB /* CleanupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AEFAD66300B87C16924F4D7 /* CleanupModels.swift */; }; E16DD32548BBD2D1A11C9C92 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E0A4F6821D870D3E6D7222 /* RootView.swift */; }; + E2A1FB396CE5BDACF63C9D9E /* AlfredRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2575EBBEE5EC6B202760B /* AlfredRule.swift */; }; + E41739F33D7BBF38F05583A8 /* DeveloperComponentsDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B00A5A77A7B76F9E8B32699 /* DeveloperComponentsDetectorTests.swift */; }; + E431CA5B1E024E10C3EDCC1F /* NordVPNRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB01768AF4C46245AB56A1E /* NordVPNRule.swift */; }; + E4BE2B939FD82E46F4DBFD8D /* Homebrew.json in Resources */ = {isa = PBXBuildFile; fileRef = 51EC79CD41147A35440026B2 /* Homebrew.json */; }; + E4F0A81CD5CD475EE439F95B /* AndroidStudioRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC27E2AFD7483AE0F08948D /* AndroidStudioRule.swift */; }; + E52C14F3BF545FF3A87DC7BE /* CodesignCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3CF16BCF84ADC073D395C /* CodesignCache.swift */; }; + E5D6516EE60805E6141E10F9 /* EpicGamesRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 480EE7FEF199E55A79DB5117 /* EpicGamesRule.swift */; }; E656F0232A7C09D30D3A8B70 /* UninstallerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3C1F6747CB777D0BD96AF9 /* UninstallerService.swift */; }; + E698B04DFC7EFEF7435E7DC3 /* VMwareFusionRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6C21DF84C787CF16BA357B5 /* VMwareFusionRule.swift */; }; + E741DC06901A23C9D9DC2267 /* VerificationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18410CA889BB8667BE08176 /* VerificationEngine.swift */; }; + E827D723F29B4B19E43ACE67 /* ApplicationRuleRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B1C1E9B0FFC93988C69561 /* ApplicationRuleRegistry.swift */; }; + E8FCD36679805EF63D4D8C94 /* AppIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */; }; E9773085984D381C6D8A9F53 /* LanguageManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */; }; EA0671C84021CD2AA75D58EA /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5214D1AD87C67377076C08 /* NotificationManager.swift */; }; + EB66DBBCEF94D344CA4DCFA6 /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4937E0DC790BB50CA8F8798 /* SnapshotStore.swift */; }; EDD3940083A8EC447172ED6E /* CleanupOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68F3F184D878FFEFC54B21 /* CleanupOptionsTests.swift */; }; + EDF297B65D382745EED0B1DB /* UninstallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF1EDB5329DABAA20BCAED2 /* UninstallSnapshot.swift */; }; + F00B1892329F1ED50C46574B /* EvidenceSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86C1024E8C23494818532A4 /* EvidenceSourceTests.swift */; }; + F66F8B5C81F0594E86965308 /* ConfidenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB088C6253EADF3B4E7AE7 /* ConfidenceEngine.swift */; }; F6FA71CE2DE763D8605DE9B9 /* CommandCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B27F13D467CB9E08F5B350 /* CommandCache.swift */; }; + F7247FDBD033B431021D0DE7 /* Unity.json in Resources */ = {isa = PBXBuildFile; fileRef = 8329098CAFECCA528C771F2F /* Unity.json */; }; + F9CC8AC236AEC39E5C6D9FD7 /* CommunicationRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDBEFA89D961FFCCC6803C5 /* CommunicationRule.swift */; }; + FCA4F949ED18C01812E53B56 /* VirtualizationRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8D95CDCD82E40F60BA1E6A /* VirtualizationRule.swift */; }; + FF78CCB002655FB528308A84 /* RancherDesktopRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9533ED1F79B059880118CDE /* RancherDesktopRule.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,9 +192,14 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0161725DC48B94D957228EB3 /* EmbeddedCleanupPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedCleanupPaths.swift; sourceTree = ""; }; + 03DBC8288446E7C56A7DFD28 /* Steam.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Steam.json; sourceTree = ""; }; 06B55A53EA10B7C9A1308221 /* CleanupItemManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupItemManager.swift; sourceTree = ""; }; 08402BF5E1C6101310B2B476 /* LanguageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManager.swift; sourceTree = ""; }; + 086A1B781AA3F6ACC5AFA252 /* Cursor.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Cursor.json; sourceTree = ""; }; + 08A2575EBBEE5EC6B202760B /* AlfredRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlfredRule.swift; sourceTree = ""; }; 0BB6DE85A7D910E1F0424CEF /* TransactionJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionJournalTests.swift; sourceTree = ""; }; + 0C92C74A3F932664D5001998 /* BaselineFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaselineFixture.swift; sourceTree = ""; }; 0E50B7CFC3A4DFDD52CC0EF1 /* MacOSCleanerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacOSCleanerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0F6BA430DBA22418A7A0D0D8 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; 1208E5D13CB1494773BCD1C7 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; @@ -106,76 +207,168 @@ 1587E61B0489D075EE10BA3D /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 16400AE1862B76A844B01CFC /* CleanupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupView.swift; sourceTree = ""; }; 180E53446953349EB5FB2E04 /* SystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfo.swift; sourceTree = ""; }; + 18DB8F9D655F658EFAE5FCA3 /* FinalCutProRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalCutProRule.swift; sourceTree = ""; }; + 197DA9FDB4A034B4100A2AFB /* AppDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiscovery.swift; sourceTree = ""; }; + 1AD2376BA6F50A27A67E4736 /* DeveloperComponentsDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperComponentsDetector.swift; sourceTree = ""; }; + 1B00A5A77A7B76F9E8B32699 /* DeveloperComponentsDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperComponentsDetectorTests.swift; sourceTree = ""; }; + 1D16F75C43CE1DD60ADDFE8E /* EvidenceGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceGraph.swift; sourceTree = ""; }; + 2013004E1BB0E55214A2CC26 /* Postman.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Postman.json; sourceTree = ""; }; + 20E17B97D1E3EEB28B3DDD0E /* ApplicationRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRule.swift; sourceTree = ""; }; + 20E198DF7B9109A1D7D13338 /* ProblematicAppsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblematicAppsTests.swift; sourceTree = ""; }; + 21CB088C6253EADF3B4E7AE7 /* ConfidenceEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceEngine.swift; sourceTree = ""; }; + 2204402B7AA52282DCCA42CA /* BackgroundItemsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundItemsCache.swift; sourceTree = ""; }; + 230649540105FBDF7CE4FDF6 /* RealWorldValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealWorldValidationTests.swift; sourceTree = ""; }; + 26A12A1159CFA31A969B390C /* EpicGames.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = EpicGames.json; sourceTree = ""; }; 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceCategoryTests.swift; sourceTree = ""; }; + 29B6468D52060304776E6A48 /* CloudStorageRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStorageRule.swift; sourceTree = ""; }; 2AEFAD66300B87C16924F4D7 /* CleanupModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupModels.swift; sourceTree = ""; }; 3245FE8F18E51D3347743E37 /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; 34E0A4F6821D870D3E6D7222 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 36158099B9A85FFD8B5536BA /* SafetyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyManagerTests.swift; sourceTree = ""; }; 37A116D669586B4CCC7108FC /* ProcessInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfoProvider.swift; sourceTree = ""; }; 395231352EDCF3A07221C2FB /* RetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryPolicy.swift; sourceTree = ""; }; + 3A0F61F1833BCBAD84509BE0 /* LogicProRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogicProRule.swift; sourceTree = ""; }; + 3A8D50E5702E806D30909278 /* AdobeRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdobeRule.swift; sourceTree = ""; }; 3A917B441A9E588D76A1B96E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 3AA3478B28127BDE2AAFCB8C /* ProcessCleanupActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessCleanupActor.swift; sourceTree = ""; }; + 3B2BA150F1B8BFD841175AC8 /* ParallelsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallelsRule.swift; sourceTree = ""; }; + 3BC3CF16BCF84ADC073D395C /* CodesignCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodesignCache.swift; sourceTree = ""; }; + 3C8AC033173B97C266D97DF2 /* DockerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockerRule.swift; sourceTree = ""; }; + 3DE15C2571A58D245F5A01C1 /* PlistContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlistContentCache.swift; sourceTree = ""; }; + 3EDBEFA89D961FFCCC6803C5 /* CommunicationRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicationRule.swift; sourceTree = ""; }; + 3F8D95CDCD82E40F60BA1E6A /* VirtualizationRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualizationRule.swift; sourceTree = ""; }; 4036BE6AD403CC0E552B386A /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 408FDF60DE1F0734F49ADE65 /* ProcessesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessesView.swift; sourceTree = ""; }; + 41CA662F940D168EC8F30295 /* GitClientsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitClientsRule.swift; sourceTree = ""; }; + 435BD64F29A72703BDC0B2E9 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 43AFDCF6F0B36A4DCBEB9BC9 /* ProcessSafetyPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessSafetyPolicy.swift; sourceTree = ""; }; + 44C2514ADFBB5E6A83A2FB55 /* ParentLinker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentLinker.swift; sourceTree = ""; }; + 480EE7FEF199E55A79DB5117 /* EpicGamesRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpicGamesRule.swift; sourceTree = ""; }; 4C739CAEB68AC6D0D4EF63BD /* OperationRisk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationRisk.swift; sourceTree = ""; }; 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManagerTests.swift; sourceTree = ""; }; 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupStateMachine.swift; sourceTree = ""; }; 4EA68BEDE6F540E3477849FC /* SafetyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyManager.swift; sourceTree = ""; }; + 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiscoveryTests.swift; sourceTree = ""; }; + 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentityTests.swift; sourceTree = ""; }; + 51EC79CD41147A35440026B2 /* Homebrew.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Homebrew.json; sourceTree = ""; }; 54ECA599C22CEE8383F2077D /* CleanupIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupIntegrationTests.swift; sourceTree = ""; }; 55BDBFF77A67A9141C1C5796 /* CleanupStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupStateMachineTests.swift; sourceTree = ""; }; 55FC1AE8DD7419C6804C170D /* UninstallerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallerView.swift; sourceTree = ""; }; + 586694A909DE65B53CC8EDA0 /* ApplicationRuleRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRuleRegistryTests.swift; sourceTree = ""; }; 5C6703835CE5100161449E56 /* AnimatedScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedScanView.swift; sourceTree = ""; }; + 5D24E49A24ED4E353A4D6345 /* NetworkExtensionRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionRule.swift; sourceTree = ""; }; 5D604E53BC61E11EDDC30B90 /* TrashManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashManagerTests.swift; sourceTree = ""; }; 5E1342D347B34E14E2DB3428 /* ScanActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanActor.swift; sourceTree = ""; }; + 5ED4199D09AC66B4B220BDC8 /* EvidenceProbeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceProbeTests.swift; sourceTree = ""; }; + 5F374BB86457C4BC4BDC6426 /* HomebrewRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewRule.swift; sourceTree = ""; }; + 5FA0CF16A34242F0F8DAD420 /* BackgroundItemsReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundItemsReaderTests.swift; sourceTree = ""; }; + 61AC424333F1C8F21BE5E13A /* MicrosoftOfficeRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftOfficeRule.swift; sourceTree = ""; }; + 6439AA19BF8E10021E213015 /* LittleSnitchRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LittleSnitchRule.swift; sourceTree = ""; }; + 66E2BF3CAC7C35E8A39F8E50 /* LSRegisterCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LSRegisterCache.swift; sourceTree = ""; }; + 670B4B2D58E43D6491C71D88 /* LSRegisterCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LSRegisterCacheTests.swift; sourceTree = ""; }; + 68475F1254EF60843A610CAC /* DefaultRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultRule.swift; sourceTree = ""; }; 6AB26137A4BEE8E9B97A374F /* ProcessesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessesViewModel.swift; sourceTree = ""; }; 6B3C1F6747CB777D0BD96AF9 /* UninstallerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallerService.swift; sourceTree = ""; }; + 6BCE01589664A4F3C4EA3182 /* VerificationReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationReport.swift; sourceTree = ""; }; + 70C3847C35AB0FA0E8C80210 /* BackgroundItemsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundItemsReader.swift; sourceTree = ""; }; 70E3A139737CC8A7ECBD1900 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; 725A7CDFB17CB2FC1AC59560 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7443B86838A9D24B07071D8A /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + 745899F2BC71D0F92EDC35A2 /* CleanupPathProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupPathProvider.swift; sourceTree = ""; }; 753F374AA91758652C409A39 /* TransactionJournal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionJournal.swift; sourceTree = ""; }; 779BE716C48F3D16DAB0257D /* MacOSCleaner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacOSCleaner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 78EEF9CB2BE762A51083464F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = ""; }; 78F806B7312BFC1297D8E172 /* DirectorySizeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectorySizeCache.swift; sourceTree = ""; }; 7B1DD590DCE0D34A3129D242 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; + 7BA3A02A464DC643AB220B58 /* JetBrainsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetBrainsRule.swift; sourceTree = ""; }; 817EB9AE4524A850962213CB /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 8329098CAFECCA528C771F2F /* Unity.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Unity.json; sourceTree = ""; }; 8526E6E523335A441DE17858 /* AppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsTests.swift; sourceTree = ""; }; + 906E6511D36A808F61A68A15 /* ArtifactClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactClassifierTests.swift; sourceTree = ""; }; + 90B1C1E9B0FFC93988C69561 /* ApplicationRuleRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRuleRegistry.swift; sourceTree = ""; }; 90BF90F2E933654B06A18BA4 /* MacOSCleanerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacOSCleanerApp.swift; sourceTree = ""; }; 91A11C06D6D12353C59E09EF /* FileScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileScanner.swift; sourceTree = ""; }; 93C5FD8CA29FB9E59A400223 /* TrashManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashManager.swift; sourceTree = ""; }; 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileScannerTests.swift; sourceTree = ""; }; + 9B7117987756095224817906 /* CleanupCategory+FixtureMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CleanupCategory+FixtureMapping.swift"; sourceTree = ""; }; + 9BC27E2AFD7483AE0F08948D /* AndroidStudioRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AndroidStudioRule.swift; sourceTree = ""; }; + 9BD4987C83B993EB576EB7F2 /* AdobeCC.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = AdobeCC.json; sourceTree = ""; }; + 9C213F7BAD2CB83289015B67 /* SnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStoreTests.swift; sourceTree = ""; }; 9E5214D1AD87C67377076C08 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + 9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationEngineTests.swift; sourceTree = ""; }; + 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 = ""; }; A9B27F13D467CB9E08F5B350 /* CommandCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandCache.swift; sourceTree = ""; }; A9F9733A8855228C1AC3476B /* OperationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationRecord.swift; sourceTree = ""; }; + ACFF580DF2A229E8922F2A19 /* DaVinciResolveRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaVinciResolveRule.swift; sourceTree = ""; }; + ADF1EDB5329DABAA20BCAED2 /* UninstallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallSnapshot.swift; sourceTree = ""; }; + AF3858BEEB1516C71EB65033 /* EvidenceExplanation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceExplanation.swift; sourceTree = ""; }; B0086F9F7800BFD540E5F25E /* StartupServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupServicesViewModel.swift; sourceTree = ""; }; B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanResult.swift; sourceTree = ""; }; B29CC70A6B8DA27FD15BB199 /* StartupVendorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupVendorSettingsView.swift; sourceTree = ""; }; B421C9DC6196E3977508E1CD /* NavigationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationItem.swift; sourceTree = ""; }; + B625D7C04F6EC419FA5F0764 /* LaunchctlCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchctlCache.swift; sourceTree = ""; }; B7F3794501798DF583436B09 /* CleanupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupEngine.swift; sourceTree = ""; }; B8008E88C0D03BB15C65998D /* UninstallerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallerServiceTests.swift; sourceTree = ""; }; + B86C1024E8C23494818532A4 /* EvidenceSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSourceTests.swift; sourceTree = ""; }; B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchServiceManagerTests.swift; sourceTree = ""; }; B9BB7DDAA6BEB78C47B1EAD3 /* CommandRunning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunning.swift; sourceTree = ""; }; BC107E0D03F39A8293BD8FE4 /* CleanupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupCoordinator.swift; sourceTree = ""; }; BCD1414A4103E729B6946047 /* FileCleanupActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCleanupActor.swift; sourceTree = ""; }; + BDB01768AF4C46245AB56A1E /* NordVPNRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NordVPNRule.swift; sourceTree = ""; }; + BE06D2D275499CE3A8A513EF /* UnityRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnityRule.swift; sourceTree = ""; }; + C145D57FE94451F56F45C7DD /* PlistAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlistAnalyzer.swift; sourceTree = ""; }; C1C7373D2284E648D835B122 /* ProcessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessManager.swift; sourceTree = ""; }; C26CC1A76E88A0BBF3B47E5A /* CodeSignatureInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeSignatureInfo.swift; sourceTree = ""; }; C448F6372606D03FF817C58B /* CleanupItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupItem.swift; sourceTree = ""; }; + C4937E0DC790BB50CA8F8798 /* SnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStore.swift; sourceTree = ""; }; + C6C21DF84C787CF16BA357B5 /* VMwareFusionRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMwareFusionRule.swift; sourceTree = ""; }; + C950BCC1062A364886439405 /* EvidenceProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceProbe.swift; sourceTree = ""; }; CAA2BC762F643159A5EE7AA7 /* RetryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryPolicyTests.swift; sourceTree = ""; }; + CB502AA9A1CC1FB93686DC00 /* RaycastRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaycastRule.swift; sourceTree = ""; }; CDBBE8FE6EC8F248C9C4E6B3 /* CleanupEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupEngineTests.swift; sourceTree = ""; }; CE5A3FFB9EF8E6D7D62FD68D /* LaunchServiceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchServiceManager.swift; sourceTree = ""; }; CE68F3F184D878FFEFC54B21 /* CleanupOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupOptionsTests.swift; sourceTree = ""; }; + CF1797889E54A02B080F3BE6 /* CandidateCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CandidateCollectorTests.swift; sourceTree = ""; }; CF9A4348B3774A855AC83B1B /* CleanupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupViewModel.swift; sourceTree = ""; }; + D051655B09FA196A2BD952F2 /* Evidence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Evidence.swift; sourceTree = ""; }; D12280699D34818A47785336 /* StartupServicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupServicesView.swift; sourceTree = ""; }; + D1E2968239F9E780D1928B58 /* MicrosoftOffice.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MicrosoftOffice.json; sourceTree = ""; }; + D25FF63D60CE321A50740AB3 /* BrowserRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserRule.swift; sourceTree = ""; }; + D61798A3E8B34E827E963D77 /* Confidence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Confidence.swift; sourceTree = ""; }; + D694D1ECD87105CC67BA55C1 /* PlistAnalyzerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlistAnalyzerTests.swift; sourceTree = ""; }; D8F77DB5F928748036C709D4 /* MockCommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommandRunner.swift; sourceTree = ""; }; + D976632F4535BD8561654197 /* ElectronRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElectronRule.swift; sourceTree = ""; }; + DA8B28911DC4508EB90A34A5 /* Arc.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Arc.json; sourceTree = ""; }; + DAB05B780E52D2EA5A76D570 /* XcodeRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeRule.swift; sourceTree = ""; }; + DB8F5E087C85389E6C6C2B26 /* DashboardView.swift.back */ = {isa = PBXFileReference; path = DashboardView.swift.back; sourceTree = ""; }; + DBA64165D2FBAC03ACE56DC6 /* ConfidenceEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceEngineTests.swift; sourceTree = ""; }; + DCBD05CD906BFA50A593DAA2 /* SteamRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamRule.swift; sourceTree = ""; }; + DD4DB6F7DA427DA269274700 /* NordVPN.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = NordVPN.json; sourceTree = ""; }; + DF2C09A741A9F06D78858A0C /* EvidenceExplanationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceExplanationTests.swift; sourceTree = ""; }; + E11E52499AE453EB667A3BE9 /* DatabaseToolsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseToolsRule.swift; sourceTree = ""; }; + E18410CA889BB8667BE08176 /* VerificationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationEngine.swift; sourceTree = ""; }; E71786FFCFB9821335C9B9E8 /* CleanupNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupNotifier.swift; sourceTree = ""; }; + E7479AAE9D83844B12D74B02 /* TerminalRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRule.swift; sourceTree = ""; }; E7DBFF8A61FF1E6AB77A7B98 /* RunningProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningProcess.swift; sourceTree = ""; }; + E94A572C930CB883AFC67CB5 /* CandidateCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CandidateCollector.swift; sourceTree = ""; }; + E9533ED1F79B059880118CDE /* RancherDesktopRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RancherDesktopRule.swift; sourceTree = ""; }; E9D71F05F8569A43F8BF66A8 /* CleanupTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupTransaction.swift; sourceTree = ""; }; + EA2DFE21E3297B6099B185C4 /* IdentityCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityCache.swift; sourceTree = ""; }; EAD1B0FF19F3B77B06378FAD /* StartupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupService.swift; sourceTree = ""; }; + EB19DF68D3A880EDC0DE961F /* KarabinerElementsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KarabinerElementsRule.swift; sourceTree = ""; }; EB8F8A1333721100D80BF6EE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + EFB823E397459E29F026EFB6 /* LittleSnitch.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = LittleSnitch.json; sourceTree = ""; }; + F107148C0A34FAB65F2D6093 /* ProbeCaches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProbeCaches.swift; sourceTree = ""; }; + F3D4AA64672B844EB923DFF3 /* EvidenceGraphTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceGraphTests.swift; sourceTree = ""; }; F514077EE94AA6AF042FE5B0 /* PosixScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosixScanner.swift; sourceTree = ""; }; F53482F256457BB8CB422EB4 /* ProcessRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessRow.swift; sourceTree = ""; }; + F6F9B22B36D401D28509CF70 /* ArtifactClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactClassifier.swift; sourceTree = ""; }; + F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoringWeights.swift; sourceTree = ""; }; FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunnerTests.swift; sourceTree = ""; }; + FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSource.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -236,22 +429,43 @@ 4205BD74C801701EFF72A5ED /* MacOSCleanerTests */ = { isa = PBXGroup; children = ( + 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */, + 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */, 8526E6E523335A441DE17858 /* AppSettingsTests.swift */, + 906E6511D36A808F61A68A15 /* ArtifactClassifierTests.swift */, + 5FA0CF16A34242F0F8DAD420 /* BackgroundItemsReaderTests.swift */, + 0C92C74A3F932664D5001998 /* BaselineFixture.swift */, + CF1797889E54A02B080F3BE6 /* CandidateCollectorTests.swift */, CDBBE8FE6EC8F248C9C4E6B3 /* CleanupEngineTests.swift */, 54ECA599C22CEE8383F2077D /* CleanupIntegrationTests.swift */, CE68F3F184D878FFEFC54B21 /* CleanupOptionsTests.swift */, 55BDBFF77A67A9141C1C5796 /* CleanupStateMachineTests.swift */, FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */, + DBA64165D2FBAC03ACE56DC6 /* ConfidenceEngineTests.swift */, 1208E5D13CB1494773BCD1C7 /* DashboardViewModelTests.swift */, + 1B00A5A77A7B76F9E8B32699 /* DeveloperComponentsDetectorTests.swift */, + 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */, + DF2C09A741A9F06D78858A0C /* EvidenceExplanationTests.swift */, + F3D4AA64672B844EB923DFF3 /* EvidenceGraphTests.swift */, + 5ED4199D09AC66B4B220BDC8 /* EvidenceProbeTests.swift */, + B86C1024E8C23494818532A4 /* EvidenceSourceTests.swift */, 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */, 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */, B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */, + 670B4B2D58E43D6491C71D88 /* LSRegisterCacheTests.swift */, D8F77DB5F928748036C709D4 /* MockCommandRunner.swift */, + D694D1ECD87105CC67BA55C1 /* PlistAnalyzerTests.swift */, + 20E198DF7B9109A1D7D13338 /* ProblematicAppsTests.swift */, + 230649540105FBDF7CE4FDF6 /* RealWorldValidationTests.swift */, CAA2BC762F643159A5EE7AA7 /* RetryPolicyTests.swift */, 36158099B9A85FFD8B5536BA /* SafetyManagerTests.swift */, + 9C213F7BAD2CB83289015B67 /* SnapshotStoreTests.swift */, 0BB6DE85A7D910E1F0424CEF /* TransactionJournalTests.swift */, 5D604E53BC61E11EDDC30B90 /* TrashManagerTests.swift */, B8008E88C0D03BB15C65998D /* UninstallerServiceTests.swift */, + 9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */, + 648625093D670F64ABC36287 /* Fixtures */, + BC10F6C9C525B318E5C2FAE6 /* Rules */, ); path = MacOSCleanerTests; sourceTree = ""; @@ -303,24 +517,107 @@ path = About; sourceTree = ""; }; + 648625093D670F64ABC36287 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 9BD4987C83B993EB576EB7F2 /* AdobeCC.json */, + DA8B28911DC4508EB90A34A5 /* Arc.json */, + 086A1B781AA3F6ACC5AFA252 /* Cursor.json */, + 26A12A1159CFA31A969B390C /* EpicGames.json */, + 51EC79CD41147A35440026B2 /* Homebrew.json */, + EFB823E397459E29F026EFB6 /* LittleSnitch.json */, + D1E2968239F9E780D1928B58 /* MicrosoftOffice.json */, + DD4DB6F7DA427DA269274700 /* NordVPN.json */, + 2013004E1BB0E55214A2CC26 /* Postman.json */, + 03DBC8288446E7C56A7DFD28 /* Steam.json */, + 8329098CAFECCA528C771F2F /* Unity.json */, + ); + path = Fixtures; + sourceTree = ""; + }; 6BD0CE8DB3A2303D2E44D9CA /* Uninstaller */ = { isa = PBXGroup; children = ( + 197DA9FDB4A034B4100A2AFB /* AppDiscovery.swift */, + A3BC06C9BBE41099A9D54344 /* AppIdentity.swift */, + 90B1C1E9B0FFC93988C69561 /* ApplicationRuleRegistry.swift */, + F6F9B22B36D401D28509CF70 /* ArtifactClassifier.swift */, + 70C3847C35AB0FA0E8C80210 /* BackgroundItemsReader.swift */, + E94A572C930CB883AFC67CB5 /* CandidateCollector.swift */, + D61798A3E8B34E827E963D77 /* Confidence.swift */, + 21CB088C6253EADF3B4E7AE7 /* ConfidenceEngine.swift */, + 1AD2376BA6F50A27A67E4736 /* DeveloperComponentsDetector.swift */, + D051655B09FA196A2BD952F2 /* Evidence.swift */, + AF3858BEEB1516C71EB65033 /* EvidenceExplanation.swift */, + 1D16F75C43CE1DD60ADDFE8E /* EvidenceGraph.swift */, + C950BCC1062A364886439405 /* EvidenceProbe.swift */, + FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */, + 44C2514ADFBB5E6A83A2FB55 /* ParentLinker.swift */, + C145D57FE94451F56F45C7DD /* PlistAnalyzer.swift */, + F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */, + C4937E0DC790BB50CA8F8798 /* SnapshotStore.swift */, 6B3C1F6747CB777D0BD96AF9 /* UninstallerService.swift */, 55FC1AE8DD7419C6804C170D /* UninstallerView.swift */, + ADF1EDB5329DABAA20BCAED2 /* UninstallSnapshot.swift */, + E18410CA889BB8667BE08176 /* VerificationEngine.swift */, + 6BCE01589664A4F3C4EA3182 /* VerificationReport.swift */, + B9FD9F355181631D203038E5 /* Caches */, + 75EFD5A1761A3329E2B7F49F /* Rules */, ); path = Uninstaller; sourceTree = ""; }; + 75EFD5A1761A3329E2B7F49F /* Rules */ = { + isa = PBXGroup; + children = ( + 3A8D50E5702E806D30909278 /* AdobeRule.swift */, + 08A2575EBBEE5EC6B202760B /* AlfredRule.swift */, + 9BC27E2AFD7483AE0F08948D /* AndroidStudioRule.swift */, + 20E17B97D1E3EEB28B3DDD0E /* ApplicationRule.swift */, + D25FF63D60CE321A50740AB3 /* BrowserRule.swift */, + 29B6468D52060304776E6A48 /* CloudStorageRule.swift */, + 3EDBEFA89D961FFCCC6803C5 /* CommunicationRule.swift */, + E11E52499AE453EB667A3BE9 /* DatabaseToolsRule.swift */, + ACFF580DF2A229E8922F2A19 /* DaVinciResolveRule.swift */, + 68475F1254EF60843A610CAC /* DefaultRule.swift */, + 3C8AC033173B97C266D97DF2 /* DockerRule.swift */, + D976632F4535BD8561654197 /* ElectronRule.swift */, + 480EE7FEF199E55A79DB5117 /* EpicGamesRule.swift */, + 18DB8F9D655F658EFAE5FCA3 /* FinalCutProRule.swift */, + 41CA662F940D168EC8F30295 /* GitClientsRule.swift */, + 5F374BB86457C4BC4BDC6426 /* HomebrewRule.swift */, + 7BA3A02A464DC643AB220B58 /* JetBrainsRule.swift */, + EB19DF68D3A880EDC0DE961F /* KarabinerElementsRule.swift */, + 6439AA19BF8E10021E213015 /* LittleSnitchRule.swift */, + 3A0F61F1833BCBAD84509BE0 /* LogicProRule.swift */, + 61AC424333F1C8F21BE5E13A /* MicrosoftOfficeRule.swift */, + 5D24E49A24ED4E353A4D6345 /* NetworkExtensionRule.swift */, + BDB01768AF4C46245AB56A1E /* NordVPNRule.swift */, + 3B2BA150F1B8BFD841175AC8 /* ParallelsRule.swift */, + E9533ED1F79B059880118CDE /* RancherDesktopRule.swift */, + CB502AA9A1CC1FB93686DC00 /* RaycastRule.swift */, + DCBD05CD906BFA50A593DAA2 /* SteamRule.swift */, + E7479AAE9D83844B12D74B02 /* TerminalRule.swift */, + BE06D2D275499CE3A8A513EF /* UnityRule.swift */, + 3F8D95CDCD82E40F60BA1E6A /* VirtualizationRule.swift */, + C6C21DF84C787CF16BA357B5 /* VMwareFusionRule.swift */, + DAB05B780E52D2EA5A76D570 /* XcodeRule.swift */, + ); + path = Rules; + sourceTree = ""; + }; 8B7551D2E6A1F47283437FAF /* Cleanup */ = { isa = PBXGroup; children = ( + 9B7117987756095224817906 /* CleanupCategory+FixtureMapping.swift */, BC107E0D03F39A8293BD8FE4 /* CleanupCoordinator.swift */, B7F3794501798DF583436B09 /* CleanupEngine.swift */, 06B55A53EA10B7C9A1308221 /* CleanupItemManager.swift */, 2AEFAD66300B87C16924F4D7 /* CleanupModels.swift */, E71786FFCFB9821335C9B9E8 /* CleanupNotifier.swift */, + 745899F2BC71D0F92EDC35A2 /* CleanupPathProvider.swift */, 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */, + 0161725DC48B94D957228EB3 /* EmbeddedCleanupPaths.swift */, 753F374AA91758652C409A39 /* TransactionJournal.swift */, ); path = Cleanup; @@ -353,10 +650,34 @@ path = Domains; sourceTree = ""; }; + B9FD9F355181631D203038E5 /* Caches */ = { + isa = PBXGroup; + children = ( + 2204402B7AA52282DCCA42CA /* BackgroundItemsCache.swift */, + 3BC3CF16BCF84ADC073D395C /* CodesignCache.swift */, + EA2DFE21E3297B6099B185C4 /* IdentityCache.swift */, + B625D7C04F6EC419FA5F0764 /* LaunchctlCache.swift */, + 66E2BF3CAC7C35E8A39F8E50 /* LSRegisterCache.swift */, + A9763DE94720F8AF4B9C0AE3 /* MdfindCache.swift */, + 3DE15C2571A58D245F5A01C1 /* PlistContentCache.swift */, + F107148C0A34FAB65F2D6093 /* ProbeCaches.swift */, + ); + path = Caches; + sourceTree = ""; + }; + BC10F6C9C525B318E5C2FAE6 /* Rules */ = { + isa = PBXGroup; + children = ( + 586694A909DE65B53CC8EDA0 /* ApplicationRuleRegistryTests.swift */, + ); + path = Rules; + sourceTree = ""; + }; C8BB00A9C6F9B8C48B055441 /* Dashboard */ = { isa = PBXGroup; children = ( 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */, + DB8F5E087C85389E6C6C2B26 /* DashboardView.swift.back */, A5EFE9DE1E343AF8F2490C67 /* DashboardViewModel.swift */, ); path = Dashboard; @@ -423,6 +744,7 @@ buildConfigurationList = B76288AED529D87658820DBC /* Build configuration list for PBXNativeTarget "MacOSCleanerTests" */; buildPhases = ( 2450DD33DA451ECBF07A2CE5 /* Sources */, + 25FF6942D31A5C786909C8AF /* Resources */, ); buildRules = ( ); @@ -464,9 +786,11 @@ LastUpgradeCheck = 1430; TargetAttributes = { CEC36BF64813DCAC65122CF7 = { + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; F8B6766CC629D27E42648343 = { + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; }; @@ -477,6 +801,7 @@ knownRegions = ( Base, en, + es, ru, uk, ); @@ -499,10 +824,29 @@ buildActionMask = 2147483647; files = ( 0EC337EF82F7F025681B3725 /* Assets.xcassets in Resources */, + 9EC173288EFFC99A7B34B3A7 /* DashboardView.swift.back in Resources */, C89630797CDD60C50CC75BA7 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 25FF6942D31A5C786909C8AF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A36A6F47591CFDC0161C465 /* AdobeCC.json in Resources */, + AB238D2B717AED4D59708DD7 /* Arc.json in Resources */, + 088A8B56F3BCCD74A74508A0 /* Cursor.json in Resources */, + 1665896332214762C62ED272 /* EpicGames.json in Resources */, + E4BE2B939FD82E46F4DBFD8D /* Homebrew.json in Resources */, + 20777F77163CF80A0DACE3F2 /* LittleSnitch.json in Resources */, + 31B9F1688720FBE10F8F3B46 /* MicrosoftOffice.json in Resources */, + D664F8B31004BF2B0D3B1560 /* NordVPN.json in Resources */, + B262FF81F64BA96FB492B9FD /* Postman.json in Resources */, + BC2AB3D6971549EBF359F15C /* Steam.json in Resources */, + F7247FDBD033B431021D0DE7 /* Unity.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -510,22 +854,42 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7D40740FE6271009DC9CE325 /* AppDiscoveryTests.swift in Sources */, + E8FCD36679805EF63D4D8C94 /* AppIdentityTests.swift in Sources */, 3A9F6B68130F3DAF5916234B /* AppSettingsTests.swift in Sources */, + 24BC2B8C0ADE2ECE5177F6CB /* ApplicationRuleRegistryTests.swift in Sources */, + 2AE8B02C185575DD27F1BF83 /* ArtifactClassifierTests.swift in Sources */, + B31A42615B4637F2D8C2F303 /* BackgroundItemsReaderTests.swift in Sources */, + 6815BD9B08943A3B4F2F4FF6 /* BaselineFixture.swift in Sources */, + D768D4102DD98AD91B8F4EEA /* CandidateCollectorTests.swift in Sources */, 546A45B9B4E4879BAD95CB97 /* CleanupEngineTests.swift in Sources */, AB62F80D9F4D205F5DFE4851 /* CleanupIntegrationTests.swift in Sources */, EDD3940083A8EC447172ED6E /* CleanupOptionsTests.swift in Sources */, 1BC98E1954D1AFBBCC49050D /* CleanupStateMachineTests.swift in Sources */, 775D5D2C093A529875939FC9 /* CommandRunnerTests.swift in Sources */, + 03FDBD7F79B43C582A6C0EC5 /* ConfidenceEngineTests.swift in Sources */, 977AD64777E29D8DE44620DD /* DashboardViewModelTests.swift in Sources */, + E41739F33D7BBF38F05583A8 /* DeveloperComponentsDetectorTests.swift in Sources */, + 32D15D3B2B3404DE9662CEA5 /* EvidenceCategoryTests.swift in Sources */, + 2EFB638B382F68A4A21CD7AB /* EvidenceExplanationTests.swift in Sources */, + B3519A02A233AC4C5D343D1A /* EvidenceGraphTests.swift in Sources */, + 11A7D2871426F39EFA6D740B /* EvidenceProbeTests.swift in Sources */, + F00B1892329F1ED50C46574B /* EvidenceSourceTests.swift in Sources */, 624F501955DDCE47F965A24C /* FileScannerTests.swift in Sources */, + 2D9E139399502E0900C1DA6F /* LSRegisterCacheTests.swift in Sources */, E9773085984D381C6D8A9F53 /* LanguageManagerTests.swift in Sources */, BC7D6B5A297C83B50F760387 /* LaunchServiceManagerTests.swift in Sources */, 5805C9625FA33C2762194266 /* MockCommandRunner.swift in Sources */, + 66773DCDEB6DDD828C28ECB3 /* PlistAnalyzerTests.swift in Sources */, + 726D2AB56BC7F27C7BA1332B /* ProblematicAppsTests.swift in Sources */, + 07337BBA7A5B8BEB5557019A /* RealWorldValidationTests.swift in Sources */, D5EB7139957B403064AFD099 /* RetryPolicyTests.swift in Sources */, DFBF63D178857930319DBB9B /* SafetyManagerTests.swift in Sources */, + 98DBE81A5E6044C50CC228E8 /* SnapshotStoreTests.swift in Sources */, C952B1E49F2B979D4ECE8F04 /* TransactionJournalTests.swift in Sources */, 9D8FA0DA512C5B78E8983175 /* TrashManagerTests.swift in Sources */, 51555D0F365275801D2D72F6 /* UninstallerServiceTests.swift in Sources */, + 42366FBE09FE46182F73C86C /* VerificationEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -534,38 +898,89 @@ buildActionMask = 2147483647; files = ( 5D2C3AC6D520ED512CFDE893 /* AboutView.swift in Sources */, + 338946DFC3F61C72FCC52D5E /* AdobeRule.swift in Sources */, + E2A1FB396CE5BDACF63C9D9E /* AlfredRule.swift in Sources */, + E4F0A81CD5CD475EE439F95B /* AndroidStudioRule.swift in Sources */, 8F1C2ACF00DF4A34A6CA9862 /* AnimatedScanView.swift in Sources */, + CFA170D8C84D477F84A53303 /* AppDiscovery.swift in Sources */, + AEA91CD04EACACE4AB9C75B6 /* AppIdentity.swift in Sources */, 2282CB50ADC015170FFA009C /* AppSettings.swift in Sources */, + 01FE9E2C2B37A96C1F4A8B00 /* ApplicationRule.swift in Sources */, + E827D723F29B4B19E43ACE67 /* ApplicationRuleRegistry.swift in Sources */, + 4189AEA27F9A79D1E00F7157 /* ArtifactClassifier.swift in Sources */, + C11A27392992E4545E99913A /* BackgroundItemsCache.swift in Sources */, + 87EDEF516A814FC99DCA0B31 /* BackgroundItemsReader.swift in Sources */, + 81622E787C1700C4F73D32CB /* BrowserRule.swift in Sources */, + 77C8FDB4576E9419A7ACE1E6 /* CandidateCollector.swift in Sources */, + 472B1E10D4A7B420B64CADD2 /* CleanupCategory+FixtureMapping.swift in Sources */, B74924CC7A66323AB8CD2E28 /* CleanupCoordinator.swift in Sources */, 0C6462E222FBB78EEDB1E781 /* CleanupEngine.swift in Sources */, 939FA61E08F828EA3558E544 /* CleanupItem.swift in Sources */, ADE0E2089B2A00FB56D8B068 /* CleanupItemManager.swift in Sources */, E0DD3AB3994894965CF333DB /* CleanupModels.swift in Sources */, 29F1245AEC8E7426A89D8597 /* CleanupNotifier.swift in Sources */, + 0626FC3A62573329D86A06D0 /* CleanupPathProvider.swift in Sources */, 7F80CDAE8621DF99A473AB3B /* CleanupStateMachine.swift in Sources */, D5274B5FCD758749A4974B7C /* CleanupTransaction.swift in Sources */, 370555A1E46A82B05D6374B0 /* CleanupView.swift in Sources */, 5665E32E91F5C6C213D3AAEA /* CleanupViewModel.swift in Sources */, + C4B3338F6671FDDBA46A9C15 /* CloudStorageRule.swift in Sources */, 2D5855CF50F6DF6E37046E84 /* CodeSignatureInfo.swift in Sources */, + E52C14F3BF545FF3A87DC7BE /* CodesignCache.swift in Sources */, F6FA71CE2DE763D8605DE9B9 /* CommandCache.swift in Sources */, 4BC8DDBA6B51777F46BC4BA0 /* CommandRunner.swift in Sources */, 162CB83D7194D6BD718593A0 /* CommandRunning.swift in Sources */, + F9CC8AC236AEC39E5C6D9FD7 /* CommunicationRule.swift in Sources */, + CF451E1D986FDEB59A0B0F05 /* Confidence.swift in Sources */, + F66F8B5C81F0594E86965308 /* ConfidenceEngine.swift in Sources */, + BA45213B9E4C9FC0430EBC68 /* DaVinciResolveRule.swift in Sources */, BE3FBA22FA22D8E3A69F5A34 /* DashboardView.swift in Sources */, 9655F1D4887A095D052C9271 /* DashboardViewModel.swift in Sources */, + 56FB37504DA9E64D73AD4B3A /* DatabaseToolsRule.swift in Sources */, + 63A1AF79D3508862AED5B581 /* DefaultRule.swift in Sources */, + 2FBBF85D96918E09A80C5939 /* DeveloperComponentsDetector.swift in Sources */, 73B0F634314F9E8EE2D4C768 /* DirectorySizeCache.swift in Sources */, + 0D71B75F7521BAECEAEF7119 /* DockerRule.swift in Sources */, + C50AEB6F8125492F79A73313 /* ElectronRule.swift in Sources */, + 0034381EEDFEBD9342599AB4 /* EmbeddedCleanupPaths.swift in Sources */, + E5D6516EE60805E6141E10F9 /* EpicGamesRule.swift in Sources */, + 2994E7C0CD0AB5AA8E3FD4D8 /* Evidence.swift in Sources */, + D5883737207237A630360049 /* EvidenceExplanation.swift in Sources */, + 4C9D979A3DEB7D4F0CD486FA /* EvidenceGraph.swift in Sources */, + ACA52C8CF526BCFFC6B5824C /* EvidenceProbe.swift in Sources */, + C29BE4B2467ED58C79E26C05 /* EvidenceSource.swift in Sources */, 713C1D45A6A1B3350413D996 /* FileCleanupActor.swift in Sources */, 4F36E18C78A15EADBF60C039 /* FileManager+Size.swift in Sources */, 7C59F9EB60B6D24E70FCFB2D /* FileScanner.swift in Sources */, + 597C46E020148F9810D9763B /* FinalCutProRule.swift in Sources */, + 4249D6B3C9C4B39B14606220 /* GitClientsRule.swift in Sources */, + 98E6580503F96F10F73A07C3 /* HomebrewRule.swift in Sources */, + A579926287D201B899842A4D /* IdentityCache.swift in Sources */, + 7E95E7129D7B44DCFE10E2C6 /* JetBrainsRule.swift in Sources */, + A771995E76D2909084B5E292 /* KarabinerElementsRule.swift in Sources */, + 7DEF61AD385AC14996379126 /* LSRegisterCache.swift in Sources */, 9A045237ED42AC3CC1C06031 /* LanguageManager.swift in Sources */, DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */, + 36CFC6F9A71EE6AA66E178C5 /* LaunchctlCache.swift in Sources */, + 0428CB5B74191FAFF78C6F48 /* LittleSnitchRule.swift in Sources */, + 9CEF93EA4AC1719CDF985A58 /* LogicProRule.swift in Sources */, D5736DBAF67A7BA78B39290F /* MacOSCleanerApp.swift in Sources */, + 4117A3A7BD3835699732D21D /* MdfindCache.swift in Sources */, + BD2107BAF6691B18A67B1297 /* MicrosoftOfficeRule.swift in Sources */, BB644D53B3367954A53BA10F /* NavigationItem.swift in Sources */, + A7DBDF61A41B25A92649D9E2 /* NetworkExtensionRule.swift in Sources */, + E431CA5B1E024E10C3EDCC1F /* NordVPNRule.swift in Sources */, EA0671C84021CD2AA75D58EA /* NotificationManager.swift in Sources */, 1BE271D883FB9BB89236DA15 /* OperationRecord.swift in Sources */, 5A80FF0AAC67C55F53950A6A /* OperationRisk.swift in Sources */, + 93A30220DD22B84B9473DB06 /* ParallelsRule.swift in Sources */, + 0025FDCCBE620B27A88A17A1 /* ParentLinker.swift in Sources */, 60B681FF2018B69AE6F42A8E /* PermissionsManager.swift in Sources */, 1BF512D726CDDC74358EF3B2 /* PermissionsView.swift in Sources */, + D58AAD770F518A3CF40ABC8B /* PlistAnalyzer.swift in Sources */, + 60D5F2F84783437BEABB9834 /* PlistContentCache.swift in Sources */, 3922CCC01F73A38F68A94C2A /* PosixScanner.swift in Sources */, + 513AE7B833D9E160B63C5761 /* ProbeCaches.swift in Sources */, D04C2D4589784A88C09C146A /* ProcessCleanupActor.swift in Sources */, A50B617B0D6B7F32B53C879A /* ProcessGroup.swift in Sources */, 09824927A11CC992B106D4CD /* ProcessInfoProvider.swift in Sources */, @@ -574,23 +989,36 @@ AEBD6F435B4BC89EB6A27021 /* ProcessSafetyPolicy.swift in Sources */, 9E12B9110CAF741A51367B1C /* ProcessesView.swift in Sources */, 7F6B179C933B447BD9F76FCA /* ProcessesViewModel.swift in Sources */, + FF78CCB002655FB528308A84 /* RancherDesktopRule.swift in Sources */, + 92E0F73F02E1797F191A0BCD /* RaycastRule.swift in Sources */, 2C15EA5886E20334BA912869 /* RetryPolicy.swift in Sources */, E16DD32548BBD2D1A11C9C92 /* RootView.swift in Sources */, A663BEC17B22AFC47D7098AC /* RunningProcess.swift in Sources */, CCDEB1A2EE6B7006D95A7ADC /* SafetyManager.swift in Sources */, 9F48B8272BC8F88521B69141 /* ScanActor.swift in Sources */, AEEE3F61B192C14B2BA9741D /* ScanResult.swift in Sources */, + 1CA1BC5393BE23A5F8B3F20B /* ScoringWeights.swift in Sources */, BC7F5459F452CC98A955FAEB /* SettingsView.swift in Sources */, + EB66DBBCEF94D344CA4DCFA6 /* SnapshotStore.swift in Sources */, A5582FB3CC44494C2D0C5863 /* StartupService.swift in Sources */, 4E95B67B22946FA1B8D60565 /* StartupServicesView.swift in Sources */, 8E3DECCD5B86A373058DED54 /* StartupServicesViewModel.swift in Sources */, 6B0C4DB9DEA1CEE0A40CE7B9 /* StartupVendorSettingsView.swift in Sources */, + 6A91F8F5E97E9DC385D90906 /* SteamRule.swift in Sources */, C54E43E230C79CFDE5EAD04C /* String+Localization.swift in Sources */, DE4F65F07814A5139023D54F /* SystemInfo.swift in Sources */, + 2E38FD4A6C11C4E5E11FF511 /* TerminalRule.swift in Sources */, B17DA1284BD059BAA16189CF /* TransactionJournal.swift in Sources */, 00CF78722CECD33C0634541C /* TrashManager.swift in Sources */, + EDF297B65D382745EED0B1DB /* UninstallSnapshot.swift in Sources */, E656F0232A7C09D30D3A8B70 /* UninstallerService.swift in Sources */, 2005604ECBBC2DF0D8C637F0 /* UninstallerView.swift in Sources */, + 7C4418AE4F14E22136AA7514 /* UnityRule.swift in Sources */, + E698B04DFC7EFEF7435E7DC3 /* VMwareFusionRule.swift in Sources */, + E741DC06901A23C9D9DC2267 /* VerificationEngine.swift in Sources */, + 2D792A2983E94EDE263F97BC /* VerificationReport.swift in Sources */, + FCA4F949ED18C01812E53B56 /* VirtualizationRule.swift in Sources */, + 622E4977F3A1981F681C3D8B /* XcodeRule.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -609,6 +1037,7 @@ isa = PBXVariantGroup; children = ( EB8F8A1333721100D80BF6EE /* en */, + 435BD64F29A72703BDC0B2E9 /* es */, 817EB9AE4524A850962213CB /* ru */, 7443B86838A9D24B07071D8A /* uk */, ); @@ -629,6 +1058,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GENERATE_INFOPLIST_FILE = YES; @@ -640,11 +1070,11 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_COMPILATION_MODE = singlefile; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 6.0; }; @@ -723,6 +1153,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GENERATE_INFOPLIST_FILE = YES; @@ -734,7 +1165,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -750,6 +1181,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -825,6 +1257,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift b/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift new file mode 100644 index 0000000..d498700 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import MacOSCleaner + +final class AppDiscoveryTests: XCTestCase { + func test_findAll_includesApplicationDirectories() async { + let discovery = AppDiscovery() + let urls = await discovery.findAll() + XCTAssertFalse(urls.isEmpty) + XCTAssertTrue(urls.contains { $0.pathExtension == "app" }) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift b/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift new file mode 100644 index 0000000..b9e05b0 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import MacOSCleaner + +final class AppIdentityTests: XCTestCase { + private var tempDir: URL! + + override func setUp() { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AppIdentityTests_\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + } + + func makeAppBundle(named name: String, bundleID: String) -> URL { + let appURL = tempDir.appendingPathComponent("\(name).app") + let contentsDir = appURL.appendingPathComponent("Contents") + try? FileManager.default.createDirectory(at: contentsDir, withIntermediateDirectories: true) + let plist: [String: Any] = [ + "CFBundleIdentifier": bundleID, + "CFBundleName": name, + "CFBundleExecutable": name, + "CFBundleShortVersionString": "1.0.0", + ] + let plistData = try! PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try! plistData.write(to: contentsDir.appendingPathComponent("Info.plist")) + return appURL + } + + func test_resolve_postman_identity() { + let appURL = makeAppBundle(named: "Postman", bundleID: "com.postmanlabs.mac") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertEqual(identity.appName, "Postman") + XCTAssertEqual(identity.bundleID, "com.postmanlabs.mac") + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_intellij_identity() { + let appURL = makeAppBundle(named: "IntelliJ IDEA", bundleID: "com.jetbrains.intellij") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertEqual(identity.appName, "IntelliJ IDEA") + XCTAssertEqual(identity.bundleID, "com.jetbrains.intellij") + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_discord_identity() { + let appURL = makeAppBundle(named: "Discord", bundleID: "com.hnc.Discord") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertEqual(identity.appName, "Discord") + XCTAssertEqual(identity.bundleID, "com.hnc.Discord") + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_docker_identity() { + let appURL = makeAppBundle(named: "Docker", bundleID: "com.docker.docker") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertEqual(identity.appName, "Docker") + XCTAssertEqual(identity.bundleID, "com.docker.docker") + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_postman_includes_bundleName_and_version() { + let appURL = makeAppBundle(named: "Postman", bundleID: "com.postmanlabs.mac") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertEqual(identity.bundleName, "Postman") + XCTAssertEqual(identity.bundleVersion, "1.0.0") + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_teamID_isNil_whenUnsigned() { + let appURL = makeAppBundle(named: "UnsignedApp", bundleID: "com.unsigned.app") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertNil(identity.teamID) + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift index 7c9ad73..ce58041 100644 --- a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift @@ -3,29 +3,22 @@ import XCTest @MainActor final class AppSettingsTests: XCTestCase { - private var suiteName: String! - private var defaults: UserDefaults! - override func setUp() async throws { - // Reset all settings keys to avoid pollution between test cases let keysToReset = [ "settings_language", "settings_theme", "settings_showNotifications", "settings_showTooltips", "settings_autoScanOnStartup", "settings_emptyTrashDuringCleanup", "settings_bypassTrashOnUninstall", - "settings_showRelatedFiles", "settings_emptyTrashImmediately", - "settings_skipExpertMode" + "settings_showRelatedFiles", "settings_emptyTrashImmediately" ] keysToReset.forEach { UserDefaults.standard.removeObject(forKey: $0) } } override func tearDown() async throws { - // Clean up after each test let keysToReset = [ "settings_language", "settings_theme", "settings_showNotifications", "settings_showTooltips", "settings_autoScanOnStartup", "settings_emptyTrashDuringCleanup", "settings_bypassTrashOnUninstall", - "settings_showRelatedFiles", "settings_emptyTrashImmediately", - "settings_skipExpertMode" + "settings_showRelatedFiles", "settings_emptyTrashImmediately" ] keysToReset.forEach { UserDefaults.standard.removeObject(forKey: $0) } } @@ -43,7 +36,6 @@ final class AppSettingsTests: XCTestCase { XCTAssertFalse(settings.bypassTrashOnUninstall) XCTAssertTrue(settings.showRelatedFiles) XCTAssertFalse(settings.emptyTrashImmediately) - XCTAssertFalse(settings.skipExpertMode) } // MARK: - Persistence @@ -69,14 +61,12 @@ final class AppSettingsTests: XCTestCase { settings.bypassTrashOnUninstall = true settings.showRelatedFiles = false settings.emptyTrashImmediately = true - settings.skipExpertMode = true XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_autoScanOnStartup")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_emptyTrashDuringCleanup")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_bypassTrashOnUninstall")) XCTAssertFalse(UserDefaults.standard.bool(forKey: "settings_showRelatedFiles")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_emptyTrashImmediately")) - XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_skipExpertMode")) } // MARK: - Reset @@ -92,7 +82,6 @@ final class AppSettingsTests: XCTestCase { settings.bypassTrashOnUninstall = true settings.showRelatedFiles = false settings.emptyTrashImmediately = true - settings.skipExpertMode = true settings.resetAll() @@ -105,6 +94,5 @@ final class AppSettingsTests: XCTestCase { XCTAssertFalse(settings.bypassTrashOnUninstall) XCTAssertTrue(settings.showRelatedFiles) XCTAssertFalse(settings.emptyTrashImmediately) - XCTAssertFalse(settings.skipExpertMode) } -} +} \ No newline at end of file diff --git a/MacOSCleaner/MacOSCleanerTests/ArtifactClassifierTests.swift b/MacOSCleaner/MacOSCleanerTests/ArtifactClassifierTests.swift new file mode 100644 index 0000000..20335c2 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/ArtifactClassifierTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import MacOSCleaner + +final class ArtifactClassifierTests: XCTestCase { + private func makeArtifact(path: String, score: Int, evidence: [ArtifactEvidence] = []) -> ScoredArtifact { + ScoredArtifact(url: URL(fileURLWithPath: path), score: score, evidence: evidence) + } + + func test_high_score_is_related() { + let a = makeArtifact(path: "/Users/test/Library/Application Support/Postman", score: 190) + XCTAssertEqual(ArtifactClassifier.classify(a), .related) + } + + func test_veryLikely_score_is_related() { + let a = makeArtifact(path: "/Users/test/Library/Caches/com.postmanlabs.mac", score: 80) + XCTAssertEqual(ArtifactClassifier.classify(a), .related) + } + + func test_possible_score_is_related() { + let a = makeArtifact(path: "/Users/test/Library/Preferences/com.postmanlabs.mac.plist", score: 45) + XCTAssertEqual(ArtifactClassifier.classify(a), .related) + } + + func test_low_score_is_ignored() { + let a = makeArtifact(path: "/Users/test/Library/Caches/random.tmp", score: 10) + XCTAssertEqual(ArtifactClassifier.classify(a), .ignored) + } + + func test_negative_evidence_is_ignored() { + let e = [ArtifactEvidence(source: .foreignBundleID, weight: -200)] + let a = makeArtifact(path: "/Users/test/Library/Preferences/com.adguard.mac.vpn.plist", score: -200, evidence: e) + XCTAssertEqual(ArtifactClassifier.classify(a), .ignored) + } + + func test_developer_path_is_developer() { + let a = makeArtifact(path: "/Users/test/Library/Developer/Xcode/DerivedData", score: 150) + XCTAssertEqual(ArtifactClassifier.classify(a), .developer) + } + + func test_gradle_is_developer() { + let a = makeArtifact(path: "/Users/test/.gradle", score: 120) + XCTAssertEqual(ArtifactClassifier.classify(a), .developer) + } + + func test_classifyBatch_splits_correctly() { + let related = makeArtifact(path: "/Library/Application Support/Postman", score: 190) + let developer = makeArtifact(path: "/Users/test/.gradle", score: 120) + let ignored = makeArtifact(path: "/tmp/random", score: 5) + + let (r, d, i) = ArtifactClassifier.classifyBatch([related, developer, ignored]) + XCTAssertEqual(r.count, 1) + XCTAssertEqual(d.count, 1) + XCTAssertEqual(i.count, 1) + } + + func test_systemArtifact_is_ignored_regardless_of_score() { + let e = [ArtifactEvidence(source: .systemArtifact, weight: -100)] + let a = makeArtifact(path: "/System/Library/Something", score: 200, evidence: e) + XCTAssertEqual(ArtifactClassifier.classify(a), .ignored) + } + + func test_custom_thresholds() { + let strict = ScoreThresholds(guaranteed: 200, veryLikely: 150, possible: 130) + let a = makeArtifact(path: "/Users/test/Library/Caches/App", score: 120) + XCTAssertEqual(ArtifactClassifier.classify(a, thresholds: strict), .ignored) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/BackgroundItemsReaderTests.swift b/MacOSCleaner/MacOSCleanerTests/BackgroundItemsReaderTests.swift new file mode 100644 index 0000000..8648ddf --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/BackgroundItemsReaderTests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import MacOSCleaner + +final class BackgroundItemsReaderTests: XCTestCase { + func test_readLaunchAgents_returnsPlistFiles() async { + let reader = BackgroundItemsReader() + let urls = await reader.readLaunchAgents() + XCTAssertFalse(urls.isEmpty) + XCTAssertTrue(urls.allSatisfy { $0.pathExtension == "plist" }) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/BaselineFixture.swift b/MacOSCleaner/MacOSCleanerTests/BaselineFixture.swift new file mode 100644 index 0000000..24a700b --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/BaselineFixture.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct BaselineFixture: Codable, Sendable { + public let app: String + public let bundleID: String + public let mustFind: [String] + public let mustNotFind: [String] + public let developerArtifacts: [String] + public let scoreFloor: Int + + public init( + app: String, + bundleID: String, + mustFind: [String], + mustNotFind: [String], + developerArtifacts: [String], + scoreFloor: Int + ) { + self.app = app + self.bundleID = bundleID + self.mustFind = mustFind + self.mustNotFind = mustNotFind + self.developerArtifacts = developerArtifacts + self.scoreFloor = scoreFloor + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift new file mode 100644 index 0000000..e97f1de --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import MacOSCleaner + +final class CandidateCollectorTests: XCTestCase { + func test_collect_returnsURLs() async { + let collector = CandidateCollector() + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: nil, + bundleVersion: nil, + executableName: "TestApp", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["TestVendor"], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let candidates = await collector.collect(identity: identity) + XCTAssertFalse(candidates.isEmpty) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift index 51b1550..cff4068 100644 --- a/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift @@ -53,7 +53,7 @@ final class CleanupOptionsTests: XCTestCase { let options = CleanupOptions() let categories = options.categories() - XCTAssertEqual(categories.count, 34) + XCTAssertEqual(categories.count, 48) } func testDSStoreEnabledAddsScatteredJunk() { diff --git a/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift new file mode 100644 index 0000000..e2a3999 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import MacOSCleaner + +final class ConfidenceEngineTests: XCTestCase { + func test_assess_guaranteed_with_critical_evidence() { + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: nil, + bundleVersion: nil, + executableName: "TestApp", + teamID: "ABC123", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["TestVendor"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let evidence: Set = [.bundleIDExact, .launchAgent, .teamID, .developerSignature] + let result = ConfidenceEngine.assess(evidence, identity: identity) + XCTAssertEqual(result.tier, .guaranteed) + } + + func test_assess_veryLikely_without_signature() { + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: nil, + bundleVersion: nil, + executableName: "TestApp", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let evidence: Set = [.appNameExact, .launchAgent, .container] + let result = ConfidenceEngine.assess(evidence, identity: identity) + XCTAssertEqual(result.tier, .veryLikely) + } + + func test_assess_possible_with_weak_evidence() { + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: nil, + bundleVersion: nil, + executableName: "TestApp", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["TestVendor"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let evidence: Set = [.vendorName] + let result = ConfidenceEngine.assess(evidence, identity: identity) + XCTAssertEqual(result.tier, .possible) + } + + func test_jetBrains_degradation_with_only_vendorName() { + let identity = AppIdentity( + bundleID: "com.jetbrains.intellij", + appName: "IntelliJ IDEA", + bundleName: nil, + bundleVersion: nil, + executableName: "idea", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/IntelliJ IDEA.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["JetBrains"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: true, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let evidence: Set = [.vendorName] + let result = ConfidenceEngine.assess(evidence, identity: identity) + // JetBrains degradation: vendorName-only with nonVendorCount=0 < 3 → degrades .possible to .ignore + XCTAssertEqual(result.tier, .ignore) + } + + func test_jetBrains_allows_guaranteed_with_sufficient_evidence() { + let identity = AppIdentity( + bundleID: "com.jetbrains.intellij", + appName: "IntelliJ IDEA", + bundleName: nil, + bundleVersion: nil, + executableName: "idea", + teamID: "JB123", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/IntelliJ IDEA.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["JetBrains"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: true, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let evidence: Set = [.bundleIDExact, .teamID, .launchAgent, .container, .plistContent] + let result = ConfidenceEngine.assess(evidence, identity: identity) + XCTAssertEqual(result.tier, .guaranteed) + } + + func test_ignore_when_no_evidence() { + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: nil, + bundleVersion: nil, + executableName: "TestApp", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let result = ConfidenceEngine.assess([], identity: identity) + XCTAssertEqual(result.tier, .ignore) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift b/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift new file mode 100644 index 0000000..4467a48 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import MacOSCleaner + +final class DeveloperComponentsDetectorTests: XCTestCase { + func test_detect_returnsEmptyForUnknownApp() async { + let components = await DeveloperComponentsDetector.detect( + appName: "UnknownApp", + bundleID: "com.unknown.app", + fileManager: .default + ) + XCTAssertTrue(components.isEmpty) + } + + func test_detect_returnsAndroidComponents() async { + let components = await DeveloperComponentsDetector.detect( + appName: "Android Studio", + bundleID: "com.google.android.studio", + fileManager: .default + ) + // May or may not have Android SDK installed — just check it doesn't crash + XCTAssertNotNil(components) + } + + func test_detect_returnsXcodeComponents() async { + let components = await DeveloperComponentsDetector.detect( + appName: "Xcode", + bundleID: "com.apple.dt.Xcode", + fileManager: .default + ) + XCTAssertNotNil(components) + } + + func test_detect_returnsDockerComponents() async { + let components = await DeveloperComponentsDetector.detect( + appName: "Docker", + bundleID: "com.docker.docker", + fileManager: .default + ) + XCTAssertNotNil(components) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/EvidenceCategoryTests.swift b/MacOSCleaner/MacOSCleanerTests/EvidenceCategoryTests.swift new file mode 100644 index 0000000..b12f5a3 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/EvidenceCategoryTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import MacOSCleaner + +final class EvidenceCategoryTests: XCTestCase { + func test_identity_category() { + XCTAssertEqual(Evidence.bundleIDExact.category, .identity) + XCTAssertEqual(Evidence.bundleIDPrefix.category, .identity) + XCTAssertEqual(Evidence.appNameExact.category, .identity) + XCTAssertEqual(Evidence.appNamePrefix.category, .identity) + XCTAssertEqual(Evidence.executableName.category, .identity) + XCTAssertEqual(Evidence.frameworkName.category, .identity) + XCTAssertEqual(Evidence.xpcServiceName.category, .identity) + XCTAssertEqual(Evidence.plugInName.category, .identity) + XCTAssertEqual(Evidence.vendorName.category, .identity) + } + + func test_signature_category() { + XCTAssertEqual(Evidence.teamID.category, .signature) + XCTAssertEqual(Evidence.developerSignature.category, .signature) + } + + func test_system_category() { + XCTAssertEqual(Evidence.launchAgent.category, .system) + XCTAssertEqual(Evidence.launchDaemon.category, .system) + XCTAssertEqual(Evidence.loginItem.category, .system) + XCTAssertEqual(Evidence.appGroup.category, .system) + XCTAssertEqual(Evidence.container.category, .system) + XCTAssertEqual(Evidence.extension.category, .system) + XCTAssertEqual(Evidence.xpcConnection.category, .system) + } + + func test_metadata_category() { + XCTAssertEqual(Evidence.packageReceipt.category, .metadata) + XCTAssertEqual(Evidence.plistContent.category, .metadata) + } + + func test_content_category() { + XCTAssertEqual(Evidence.spotlight.category, .content) + XCTAssertEqual(Evidence.spotlightBundleAttr.category, .content) + XCTAssertEqual(Evidence.spotlightCreator.category, .content) + XCTAssertEqual(Evidence.fileContent.category, .content) + XCTAssertEqual(Evidence.electronCache.category, .content) + XCTAssertEqual(Evidence.jetBrainsConfig.category, .content) + XCTAssertEqual(Evidence.flutterBuild.category, .content) + } + + func test_graph_category() { + XCTAssertEqual(Evidence.parentDirectory.category, .graph) + } + + func test_launchServices_category() { + XCTAssertEqual(Evidence.launchServicesRegistered.category, .launchServices) + } + + func test_allEvidenceCases_haveCategory() { + for evidence in Evidence.allCases { + let cat = evidence.category + XCTAssertNotEqual(cat.rawValue, "") + } + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/EvidenceExplanationTests.swift b/MacOSCleaner/MacOSCleanerTests/EvidenceExplanationTests.swift new file mode 100644 index 0000000..1c30073 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/EvidenceExplanationTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import MacOSCleaner + +final class EvidenceExplanationTests: XCTestCase { + func test_explanation_for_bundleIDExact() { + let exp = EvidenceExplanations.explanation(for: .bundleIDExact) + XCTAssertEqual(exp.evidence, .bundleIDExact) + XCTAssertEqual(exp.category, .identity) + XCTAssertFalse(exp.title.isEmpty) + XCTAssertFalse(exp.description.isEmpty) + } + + func test_explanation_for_teamID_withArg() { + let exp = EvidenceExplanations.explanation(for: .teamID, args: "ABC123") + XCTAssertEqual(exp.evidence, .teamID) + XCTAssertFalse(exp.description.isEmpty) + } + + func test_explanations_groupedByCategory() { + let evidence: Set = [.bundleIDExact, .teamID, .launchAgent] + let grouped = EvidenceExplanations.explanations(for: evidence) + XCTAssertNotNil(grouped[.identity]) + XCTAssertNotNil(grouped[.signature]) + XCTAssertNotNil(grouped[.system]) + } + + func test_explanations_withContext_bundleIDPrefix() { + let ctx = ExplanationContext(bundleID: "com.test.app", appName: "TestApp", teamID: nil) + let evidence: Set = [.bundleIDPrefix] + let grouped = EvidenceExplanations.explanations(for: evidence, context: ctx) + let exps = grouped[.identity] + XCTAssertEqual(exps?.count, 1) + } + + func test_explanations_withContext_teamID() { + let ctx = ExplanationContext(bundleID: nil, appName: "App", teamID: "TEAM123") + let evidence: Set = [.teamID] + let grouped = EvidenceExplanations.explanations(for: evidence, context: ctx) + let exps = grouped[.signature] + XCTAssertEqual(exps?.count, 1) + } + + func test_explanations_sortedByRawValue() { + let evidence: Set = [.teamID, .bundleIDExact, .vendorName] + let grouped = EvidenceExplanations.explanations(for: evidence) + for (_, exps) in grouped { + for i in 1.. = [.bundleIDExact] + let evidence = set.artifactEvidence() + let bundleID = evidence.first { $0.source == .bundleID } + XCTAssertEqual(bundleID?.weight, 100) + } + + func test_teamID_evidence_weight() { + let set: Set = [.teamID] + let evidence = set.artifactEvidence() + let teamID = evidence.first { $0.source == .teamID } + XCTAssertEqual(teamID?.weight, 50) + } + + func test_negative_evidence_weight() { + let weights = ScoringWeights.default + XCTAssertEqual(weights.weight(for: .foreignBundleID), -200) + XCTAssertEqual(weights.weight(for: .foreignTeamID), -150) + XCTAssertEqual(weights.weight(for: .systemArtifact), -100) + } + + func test_artifactEvidence_deduplicates_sources() { + let set: Set = [.bundleIDExact, .bundleIDPrefix] + let evidence = set.artifactEvidence() + let bundleIDCount = evidence.filter { $0.source == .bundleID }.count + XCTAssertEqual(bundleIDCount, 1) + } + + func test_scoredArtifact_total_score() { + let evidence: [ArtifactEvidence] = [ + ArtifactEvidence(source: .bundleID, weight: 100), + ArtifactEvidence(source: .teamID, weight: 50), + ] + let artifact = ScoredArtifact(url: URL(fileURLWithPath: "/test"), score: 150, evidence: evidence) + XCTAssertEqual(artifact.score, 150) + } + + func test_scoreThresholds_defaults() { + let t = ScoreThresholds.default + XCTAssertEqual(t.guaranteed, 100) + XCTAssertEqual(t.veryLikely, 60) + XCTAssertEqual(t.possible, 30) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/AdobeCC.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/AdobeCC.json new file mode 100644 index 0000000..40e5a3f --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/AdobeCC.json @@ -0,0 +1,16 @@ +{ + "app": "Adobe Creative Cloud", + "bundleID": "com.adobe.acc.AdobeCreativeCloud", + "mustFind": [ + "Application Support/Adobe", + "Preferences/Adobe", + "~/Library/Caches/com.adobe.acc" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [ + "~/Library/Application Support/Adobe/Creative Cloud Libraries" + ], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Arc.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Arc.json new file mode 100644 index 0000000..924b95c --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Arc.json @@ -0,0 +1,14 @@ +{ + "app": "Arc", + "bundleID": "company.thebrowser.Browser", + "mustFind": [ + "~/Library/Application Support/Arc", + "~/Library/Caches/Arc" + ], + "mustNotFind": [ + "com.apple.", + "com.google.Chrome" + ], + "developerArtifacts": [], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Cursor.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Cursor.json new file mode 100644 index 0000000..baeb1a2 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Cursor.json @@ -0,0 +1,21 @@ +{ + "app": "Cursor", + "bundleID": "com.todesktop.230313mzl4w4u92", + "mustFind": [ + "~/Library/Application Support/Cursor", + "~/Library/Caches/com.todesktop.230313mzl4w4u92", + "~/Library/Preferences/com.todesktop.230313mzl4w4u92.plist", + "~/Library/Saved Application State/com.todesktop.230313mzl4w4u92.savedState", + "~/Library/Logs/Cursor" + ], + "mustNotFind": [ + "com.adguard.", + "com.apple.", + "com.microsoft.VSCode" + ], + "developerArtifacts": [ + "~/Library/Application Support/Cursor/generated", + "~/Library/Application Support/Cursor/CachedExtensionVSIXs" + ], + "scoreFloor": 30 +} \ No newline at end of file diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/EpicGames.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/EpicGames.json new file mode 100644 index 0000000..cbae6db --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/EpicGames.json @@ -0,0 +1,15 @@ +{ + "app": "EpicGames", + "bundleID": "com.epicgames.EpicGamesLauncher", + "mustFind": [ + "Epic", + "~/Library/Caches/com.epicgames.EpicGamesLauncher" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [ + "~/.config/Epic" + ], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Homebrew.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Homebrew.json new file mode 100644 index 0000000..55de760 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Homebrew.json @@ -0,0 +1,15 @@ +{ + "app": "Homebrew", + "bundleID": "com.homebrew.brew", + "mustFind": [ + "/opt/homebrew", + "/usr/local/Homebrew" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [ + "~/Library/Caches/Homebrew" + ], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/LittleSnitch.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/LittleSnitch.json new file mode 100644 index 0000000..528194a --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/LittleSnitch.json @@ -0,0 +1,13 @@ +{ + "app": "LittleSnitch", + "bundleID": "at.obdev.littlesnitch", + "mustFind": [ + "LaunchDaemons", + "~/Library/Application Support/Little Snitch" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/MicrosoftOffice.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/MicrosoftOffice.json new file mode 100644 index 0000000..9b10c20 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/MicrosoftOffice.json @@ -0,0 +1,14 @@ +{ + "app": "MicrosoftOffice", + "bundleID": "com.microsoft.office", + "mustFind": [ + "UBF8T346G9.Office", + "licensingV2", + "~/Library/Containers/com.microsoft.Word" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/NordVPN.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/NordVPN.json new file mode 100644 index 0000000..015d367 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/NordVPN.json @@ -0,0 +1,14 @@ +{ + "app": "NordVPN", + "bundleID": "com.nordvpn.macos", + "mustFind": [ + "LaunchDaemons", + "PrivilegedHelperTools", + "~/Library/Caches/com.nordvpn.macos" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Postman.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Postman.json new file mode 100644 index 0000000..4b92602 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Postman.json @@ -0,0 +1,22 @@ +{ + "app": "Postman", + "bundleID": "com.postmanlabs.mac", + "mustFind": [ + "~/Library/Application Support/Postman", + "~/Library/Caches/com.postmanlabs.mac", + "~/Library/Preferences/com.postmanlabs.mac.plist", + "~/Library/Saved Application State/com.postmanlabs.mac.savedState", + "~/Library/Logs/Postman", + "~/Library/Cookies/com.postmanlabs.mac.binarycookies" + ], + "mustNotFind": [ + "com.adguard.", + "com.apple.", + "com.postmanlabs.mac.ShipIt", + "com.postmanlabs.mac.ShipIt.plist" + ], + "developerArtifacts": [ + "~/Library/Application Support/Postman/generated" + ], + "scoreFloor": 30 +} \ No newline at end of file diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Steam.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Steam.json new file mode 100644 index 0000000..a9b5d48 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Steam.json @@ -0,0 +1,15 @@ +{ + "app": "Steam", + "bundleID": "com.valvesoftware.steam", + "mustFind": [ + "Application Support/Steam", + "~/Library/Caches/com.valvesoftware.Steam" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [ + "~/Library/Application Support/Steam/steamapps" + ], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/Fixtures/Unity.json b/MacOSCleaner/MacOSCleanerTests/Fixtures/Unity.json new file mode 100644 index 0000000..c457fed --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Fixtures/Unity.json @@ -0,0 +1,15 @@ +{ + "app": "Unity", + "bundleID": "com.unity3d.UnityEditor5", + "mustFind": [ + "~/Library/Application Support/Unity", + "~/Library/Caches/com.unity3d.UnityEditor5" + ], + "mustNotFind": [ + "com.apple." + ], + "developerArtifacts": [ + "~/Library/Application Support/Unity/Asset Store" + ], + "scoreFloor": 30 +} diff --git a/MacOSCleaner/MacOSCleanerTests/LSRegisterCacheTests.swift b/MacOSCleaner/MacOSCleanerTests/LSRegisterCacheTests.swift new file mode 100644 index 0000000..5894396 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/LSRegisterCacheTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import MacOSCleaner + +final class LSRegisterCacheTests: XCTestCase { + var tempFile: URL! + + override func setUp() { + tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent("LSRegisterCacheTest_\(UUID().uuidString)") + FileManager.default.createFile(atPath: tempFile.path, contents: Data()) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempFile) + } + + func test_setAndGet() async { + let cache = LSRegisterCache() + await cache.set(bundleID: "com.test.app", url: tempFile) + let retrieved = await cache.get(bundleID: "com.test.app") + XCTAssertEqual(retrieved?.resolvingSymlinksInPath(), tempFile.resolvingSymlinksInPath()) + } + + func test_get_unknownReturnsNil() async { + let cache = LSRegisterCache() + let result = await cache.get(bundleID: "com.nonexistent.app") + XCTAssertNil(result) + } + + func test_warmup_doesNotCrash() async { + let cache = LSRegisterCache() + await cache.warmup() + } + + func test_warmup_twiceNoCrash() async { + let cache = LSRegisterCache() + await cache.warmup() + await cache.warmup() + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/LanguageManagerTests.swift b/MacOSCleaner/MacOSCleanerTests/LanguageManagerTests.swift index 2623903..dc388b7 100644 --- a/MacOSCleaner/MacOSCleanerTests/LanguageManagerTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/LanguageManagerTests.swift @@ -5,7 +5,7 @@ final class LanguageManagerTests: XCTestCase { override func setUp() { super.setUp() - // Сбросим язык по умолчанию на английский перед каждым тестом + // Reset to English before each test LanguageManager.shared.setLanguage(.english) } @@ -23,8 +23,12 @@ final class LanguageManagerTests: XCTestCase { XCTAssertEqual("welcome_msg".localized, "З поверненням!") } + func testLocalizationSwitchToSpanish() { + LanguageManager.shared.setLanguage(.spanish) + XCTAssertEqual("welcome_msg".localized, "¡Bienvenido de nuevo!") + } + func testLocalizationWithArgs() { - // Мы не добавляли ключей с аргументами, но можем проверить форматирование let template = "Hello %@" let formatted = template.localizedWithArgs("Alex") XCTAssertEqual(formatted, "Hello Alex") diff --git a/MacOSCleaner/MacOSCleanerTests/MockCommandRunner.swift b/MacOSCleaner/MacOSCleanerTests/MockCommandRunner.swift index 1bae09c..7566c4a 100644 --- a/MacOSCleaner/MacOSCleanerTests/MockCommandRunner.swift +++ b/MacOSCleaner/MacOSCleanerTests/MockCommandRunner.swift @@ -41,4 +41,54 @@ final class MockCommandRunner: CommandRunning, @unchecked Sendable { func commandExists(_ command: String) async -> Bool { availableCommands.contains(command) || commandExistsResult(command) } + + // Forensics engine mock helpers + func mockCodesignTeamID(for url: URL, teamID: String) { + runHandler = { cmd, args in + if cmd == "/usr/bin/codesign" { + if args.contains("-dv") { + if teamID.isEmpty { + return CommandResult(stdout: "", stderr: "code object is not signed at all", exitCode: 1) + } + return CommandResult( + stdout: "", + stderr: """ + Executable=\(url.path) + Format=bundle with Mach-O thin (x86_64) + CodeDirectory v=20500 size=12345 flags=0x1000(runtime) hashes=123 + TeamIdentifier=\(teamID) + """, + exitCode: 0 + ) + } + } + throw CommandRunnerError.executionFailed(1) + } + } + + func mockMdfind(query: String, results: [String]) { + let original = runHandler + runHandler = { cmd, args in + if cmd == "/usr/bin/mdfind", args.contains(query) { + return CommandResult(stdout: results.joined(separator: "\n"), stderr: "", exitCode: 0) + } + if let handler = original { + return try await handler(cmd, args) + } + return CommandResult(stdout: "", stderr: "", exitCode: 0) + } + } + + func mockPkgutil(bundleID: String, files: [String]) { + let original = runHandler + runHandler = { cmd, args in + if cmd == "/usr/sbin/pkgutil", args.contains("--files"), args.contains(bundleID) { + return CommandResult(stdout: files.joined(separator: "\n"), stderr: "", exitCode: 0) + } + if let handler = original { + return try await handler(cmd, args) + } + return CommandResult(stdout: "", stderr: "", exitCode: 0) + } + } } diff --git a/MacOSCleaner/MacOSCleanerTests/PlistAnalyzerTests.swift b/MacOSCleaner/MacOSCleanerTests/PlistAnalyzerTests.swift new file mode 100644 index 0000000..e0e47ad --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/PlistAnalyzerTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import MacOSCleaner + +final class PlistAnalyzerTests: XCTestCase { + private var tempDir: URL! + private var plistCache: PlistContentCache! + + override func setUp() { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("PlistAnalyzerTests_\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + plistCache = PlistContentCache() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + } + + func makePlist(named name: String, bundleID: String) -> URL { + let url = tempDir.appendingPathComponent(name) + let plist: [String: Any] = [ + "CFBundleIdentifier": bundleID, + "CFBundleName": "TestApp", + ] + let data = try! PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try! data.write(to: url) + return url + } + + func test_analyze_finds_matching_plist() async { + let plistURL = makePlist(named: "com.test.app.plist", bundleID: "com.test.app") + let identity = AppIdentity( + bundleID: "com.test.app", + appName: "TestApp", + bundleName: "TestApp", + bundleVersion: nil, + executableName: "TestApp", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["TestVendor"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + + let analyzer = PlistAnalyzer(fileManager: .default, plistCache: plistCache) + // Override search dirs to just our temp dir + // We'll test the cache hit directly + let content = await plistCache.getContent(url: plistURL) + XCTAssertNotNil(content) + XCTAssertTrue(content!.contains("com.test.app")) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/ProblematicAppsTests.swift b/MacOSCleaner/MacOSCleanerTests/ProblematicAppsTests.swift new file mode 100644 index 0000000..208056f --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/ProblematicAppsTests.swift @@ -0,0 +1,459 @@ +import XCTest +@testable import MacOSCleaner + +final class ProblematicAppsTests: XCTestCase { + + // MARK: - Rule Matching Tests + + func test_adobeRule_matches_creativeCloud() { + let rule = AdobeRule() + let identity = makeIdentity( + bundleID: "com.adobe.ccx.process", + appName: "Adobe Creative Cloud", + teamID: "JQ5W7278T3" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_adobeRule_matches_photoshop() { + let rule = AdobeRule() + let identity = makeIdentity( + bundleID: "com.adobe.Photoshop", + appName: "Adobe Photoshop", + teamID: "JQ5W7278T3" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_adobeRule_matches_byNamePrefix() { + let rule = AdobeRule() + let identity = makeIdentity( + bundleID: "com.unknown.adobe.app", + appName: "Adobe Something" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_adobeRule_doesNotMatch_unrelated() { + let rule = AdobeRule() + let identity = makeIdentity( + bundleID: "com.apple.Safari", + appName: "Safari" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_adobeRule_evidence_forApplicationSupport() { + let rule = AdobeRule() + let identity = makeIdentity(bundleID: "com.adobe.Photoshop", appName: "Adobe Photoshop") + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/Adobe/Photoshop") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .appName }) + } + + func test_adobeRule_evidence_forLaunchDaemon() { + let rule = AdobeRule() + let identity = makeIdentity(bundleID: "com.adobe.ccx.process", appName: "Adobe Creative Cloud") + let url = URL(fileURLWithPath: "/Library/LaunchDaemons/com.adobe.installer.clean.plist") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .bundleID }) + } + + func test_microsoftOfficeRule_matches_word() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity( + bundleID: "com.microsoft.word", + appName: "Microsoft Word", + teamID: "UBF8T346G9" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_microsoftOfficeRule_matches_teams() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity( + bundleID: "com.microsoft.teams", + appName: "Microsoft Teams", + teamID: "UBF8T346G9" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_microsoftOfficeRule_matches_byTeamID() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity( + bundleID: "com.microsoft.custom", + appName: "Microsoft Something", + teamID: "UBF8T346G9" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_microsoftOfficeRule_doesNotMatch_nonMS() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity( + bundleID: "com.google.Chrome", + appName: "Google Chrome" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_microsoftOfficeRule_evidence_forGroupContainer() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity(bundleID: "com.microsoft.word", appName: "Microsoft Word") + let url = URL(fileURLWithPath: "/Users/test/Library/Group Containers/UBF8T346G9.Office") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .bundleID }) + } + + func test_microsoftOfficeRule_evidence_forMAU() { + let rule = MicrosoftOfficeRule() + let identity = makeIdentity(bundleID: "com.microsoft.word", appName: "Microsoft Word") + let url = URL(fileURLWithPath: "/Library/Application Support/Microsoft/MAU2.0") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_steamRule_matches() { + let rule = SteamRule() + let identity = makeIdentity( + bundleID: "com.valvesoftware.steam", + appName: "Steam", + teamID: "MXG3986M2V" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_steamRule_doesNotMatch_epic() { + let rule = SteamRule() + let identity = makeIdentity( + bundleID: "com.epicgames.EpicGamesLauncher", + appName: "Epic Games Launcher" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_steamRule_evidence_forSteamApps() { + let rule = SteamRule() + let identity = makeIdentity(bundleID: "com.valvesoftware.steam", appName: "Steam") + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/Steam/steamapps") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_steamRule_evidence_forCompatData() { + let rule = SteamRule() + let identity = makeIdentity(bundleID: "com.valvesoftware.steam", appName: "Steam") + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/Steam/steamapps/compatdata/12345") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_epicGamesRule_matches() { + let rule = EpicGamesRule() + let identity = makeIdentity( + bundleID: "com.epicgames.EpicGamesLauncher", + appName: "Epic Games Launcher", + teamID: "95JQ5223G6" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_epicGamesRule_doesNotMatch_steam() { + let rule = EpicGamesRule() + let identity = makeIdentity( + bundleID: "com.valvesoftware.steam", + appName: "Steam" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_epicGamesRule_evidence_forVaultCache() { + let rule = EpicGamesRule() + let identity = makeIdentity(bundleID: "com.epicgames.EpicGamesLauncher", appName: "Epic Games Launcher") + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/Epic/VaultCache") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_unityRule_matches() { + let rule = UnityRule() + let identity = makeIdentity( + bundleID: "com.unity3d.unityhub", + appName: "Unity Hub", + teamID: "7S365J7V36" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_unityRule_doesNotMatch_unrelated() { + let rule = UnityRule() + let identity = makeIdentity( + bundleID: "com.epicgames.EpicGamesLauncher", + appName: "Epic Games Launcher" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_unityRule_evidence_forPackageCache() { + let rule = UnityRule() + let identity = makeIdentity(bundleID: "com.unity3d.unityhub", appName: "Unity Hub") + let url = URL(fileURLWithPath: "/Users/test/Library/Unity/PackageCache") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_homebrewRule_matches() { + let rule = HomebrewRule() + let identity = makeIdentity( + bundleID: "N/A (CLI tool)", + appName: "Homebrew" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_homebrewRule_doesNotMatch() { + let rule = HomebrewRule() + let identity = makeIdentity( + bundleID: "com.apple.Safari", + appName: "Safari" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_homebrewRule_evidence_forOptHomebrew() { + let rule = HomebrewRule() + let identity = makeIdentity(bundleID: "N/A (CLI tool)", appName: "Homebrew") + let url = URL(fileURLWithPath: "/opt/homebrew") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_homebrewRule_evidence_forCaskroom() { + let rule = HomebrewRule() + let identity = makeIdentity(bundleID: "N/A (CLI tool)", appName: "Homebrew") + let url = URL(fileURLWithPath: "/opt/homebrew/Caskroom/firefox") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_networkExtensionRule_matches_littleSnitch() { + let rule = NetworkExtensionRule() + let identity = makeIdentity( + bundleID: "at.obdev.littlesnitch", + appName: "Little Snitch" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_networkExtensionRule_matches_nordVPN() { + let rule = NetworkExtensionRule() + let identity = makeIdentity( + bundleID: "com.nordvpn.macos", + appName: "NordVPN" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_networkExtensionRule_matches_byNameContains() { + let rule = NetworkExtensionRule() + let identity = makeIdentity( + bundleID: "com.unknown.vpn", + appName: "Super VPN Client" + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_networkExtensionRule_doesNotMatch_unrelated() { + let rule = NetworkExtensionRule() + let identity = makeIdentity( + bundleID: "com.apple.Safari", + appName: "Safari" + ) + XCTAssertFalse(rule.matches(identity: identity)) + } + + func test_networkExtensionRule_evidence_forSystemExtensions() { + let rule = NetworkExtensionRule() + let identity = makeIdentity(bundleID: "at.obdev.littlesnitch", appName: "Little Snitch") + let url = URL(fileURLWithPath: "/Library/SystemExtensions/at.obdev.LittleSnitchNetworkExtension.systemextension") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } + + func test_networkExtensionRule_evidence_forLaunchDaemon() { + let rule = NetworkExtensionRule() + let identity = makeIdentity(bundleID: "at.obdev.littlesnitch", appName: "Little Snitch") + let url = URL(fileURLWithPath: "/Library/LaunchDaemons/at.obdev.littlesnitch.agent.plist") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .bundleID }) + } + + // MARK: - Registry Integration Tests + + func test_registry_returns_adobeRule_for_adobeApp() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.adobe.Photoshop", + appName: "Adobe Photoshop", + teamID: "JQ5W7278T3" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Adobe") + } + + func test_registry_returns_officeRule_for_word() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.microsoft.word", + appName: "Microsoft Word", + teamID: "UBF8T346G9" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Microsoft Office") + } + + func test_registry_returns_steamRule() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.valvesoftware.steam", + appName: "Steam" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Steam") + } + + func test_registry_returns_epicRule() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.epicgames.EpicGamesLauncher", + appName: "Epic Games Launcher" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Epic Games") + } + + func test_registry_returns_unityRule() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.unity3d.unityhub", + appName: "Unity Hub" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Unity") + } + + func test_registry_returns_networkExtensionRule_for_littleSnitch() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "at.obdev.littlesnitch", + appName: "Little Snitch" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Little Snitch") + } + + func test_registry_returns_networkExtensionRule_for_nordVPN() async { + let registry = ApplicationRuleRegistry.createDefault() + let identity = makeIdentity( + bundleID: "com.nordvpn.macos", + appName: "NordVPN" + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "NordVPN") + } + + // MARK: - Fixture Loading Tests + + func test_loadAllFixtures() throws { + let fixtureNames = [ + "AdobeCC", "MicrosoftOffice", "Steam", "EpicGames", + "Unity", "Homebrew", "LittleSnitch", "NordVPN", "Arc", + ] + for name in fixtureNames { + let fixture = try loadFixture(name) + XCTAssertEqual(fixture.app, name.replacingOccurrences(of: "CC", with: " Creative Cloud") + .replacingOccurrences(of: "Homebrew", with: "Homebrew") + .replacingOccurrences(of: "Arc", with: "Arc")) + XCTAssertFalse(fixture.mustFind.isEmpty, "\(name) mustFind is empty") + XCTAssertGreaterThanOrEqual(fixture.scoreFloor, 0, "\(name) scoreFloor is negative") + } + } + + func test_fixture_adobeCC_hasExpectedPaths() throws { + let fixture = try loadFixture("AdobeCC") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("Application Support/Adobe") }) + XCTAssertTrue(fixture.mustFind.contains { $0.contains("Preferences/Adobe") }) + } + + func test_fixture_microsoftOffice_hasGroupContainers() throws { + let fixture = try loadFixture("MicrosoftOffice") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("UBF8T346G9.Office") }) + XCTAssertTrue(fixture.mustFind.contains { $0.contains("licensingV2") }) + } + + func test_fixture_steam_hasSteamApps() throws { + let fixture = try loadFixture("Steam") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("Application Support/Steam") }) + XCTAssertFalse(fixture.developerArtifacts.isEmpty) + } + + func test_fixture_epicGames_hasVaultCache() throws { + let fixture = try loadFixture("EpicGames") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("Epic") }) + XCTAssertFalse(fixture.developerArtifacts.isEmpty) + } + + func test_fixture_littleSnitch_hasLaunchDaemons() throws { + let fixture = try loadFixture("LittleSnitch") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("LaunchDaemons") }) + } + + func test_fixture_nordVPN_hasLaunchDaemons() throws { + let fixture = try loadFixture("NordVPN") + XCTAssertTrue(fixture.mustFind.contains { $0.contains("LaunchDaemons") }) + XCTAssertTrue(fixture.mustFind.contains { $0.contains("PrivilegedHelperTools") }) + } + + // MARK: - Helpers + + private func makeIdentity( + bundleID: String, + appName: String, + teamID: String? = nil + ) -> AppIdentity { + AppIdentity( + bundleID: bundleID, + appName: appName, + bundleName: appName, + bundleVersion: "1.0", + executableName: appName, + teamID: teamID, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(appName).app"), + isAppStore: false, + isSandboxed: false, + isAdHocSigned: false, + vendorNames: [], + helperNames: [], + frameworkNames: [], + xpcServiceNames: [], + plugInNames: [], + isElectron: false, + isJetBrains: false, + isFlutter: false, + isJava: false, + isQt: false, + isDocker: false + ) + } + + private func loadFixture(_ name: String) throws -> BaselineFixture { + let bundle = Bundle(for: Self.self) + guard let url = bundle.url(forResource: name, withExtension: "json") else { + throw TestError.fixtureNotFound(name) + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(BaselineFixture.self, from: data) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/RealWorldValidationTests.swift b/MacOSCleaner/MacOSCleanerTests/RealWorldValidationTests.swift new file mode 100644 index 0000000..434232d --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/RealWorldValidationTests.swift @@ -0,0 +1,134 @@ +import Testing +import Foundation +@testable import MacOSCleaner + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil)) +struct RealWorldValidationTests { + + private func loadFixture(_ name: String) throws -> BaselineFixture { + let bundle = Bundle(identifier: "input.MacOSCleanerTests") ?? Bundle.main + guard let url = bundle.url(forResource: name, withExtension: "json") else { + throw TestError.fixtureNotFound(name) + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(BaselineFixture.self, from: data) + } + + private func runFullPipeline(for identity: AppIdentity) async -> (related: [ClassifiedArtifact], developer: [ClassifiedArtifact], ignored: [ClassifiedArtifact]) { + let collector = CandidateCollector(fileManager: .default, commandRunner: CommandRunner()) + let candidates = await collector.collect(identity: identity) + + let probe = EvidenceProbe(commandRunner: CommandRunner(), codesignCache: CodesignCache(), plistCache: PlistContentCache()) + + var artifacts: [ScoredArtifact] = [] + for url in candidates { + let evidence = await probe.probe(url: url, identity: identity) + guard !evidence.isEmpty else { continue } + + let artifactEvidence = evidence.artifactEvidence(weights: .default) + let score = artifactEvidence.reduce(0) { $0 + $1.weight } + + artifacts.append(ScoredArtifact(url: url, score: score, evidence: artifactEvidence)) + } + + return ArtifactClassifier.classifyBatch(artifacts, thresholds: .default) + } + + @Test("Postman baseline", .tags(.baseline)) + func testPostmanBaseline() async throws { + let fixture = try loadFixture("Postman") + let identity = AppIdentity( + bundleID: fixture.bundleID, + appName: fixture.app, + bundleName: fixture.app, + bundleVersion: "1.0", + executableName: fixture.app, + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(fixture.app).app"), + isAppStore: false, + isSandboxed: false, + isAdHocSigned: false, + vendorNames: [], + helperNames: [], + frameworkNames: [], + xpcServiceNames: [], + plugInNames: [], + isElectron: true, + isJetBrains: false, + isFlutter: false, + isJava: false, + isQt: false, + isDocker: false + ) + + let result = await runFullPipeline(for: identity) + + for path in fixture.mustFind { + let expanded = NSString(string: path).expandingTildeInPath + #expect(result.related.contains { $0.artifact.url.path.hasSuffix(expanded) } || + result.developer.contains { $0.artifact.url.path.hasSuffix(expanded) }, + "Must find: \(path)") + } + + for pattern in fixture.mustNotFind { + #expect((result.related + result.developer).allSatisfy { !$0.artifact.url.path.contains(pattern) }, + "Must not find: \(pattern)") + } + + let minScore = min(result.related.map(\.artifact.score).min() ?? 100, + result.developer.map(\.artifact.score).min() ?? 100) + #expect(minScore >= fixture.scoreFloor, "Score floor \(fixture.scoreFloor) not met (min was \(minScore))") + } + + @Test("Cursor baseline", .tags(.baseline)) + func testCursorBaseline() async throws { + let fixture = try loadFixture("Cursor") + let identity = AppIdentity( + bundleID: fixture.bundleID, + appName: fixture.app, + bundleName: fixture.app, + bundleVersion: "1.0", + executableName: fixture.app, + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(fixture.app).app"), + isAppStore: false, + isSandboxed: false, + isAdHocSigned: false, + vendorNames: [], + helperNames: [], + frameworkNames: ["Electron Framework"], + xpcServiceNames: [], + plugInNames: [], + isElectron: true, + isJetBrains: false, + isFlutter: false, + isJava: false, + isQt: false, + isDocker: false + ) + + let result = await runFullPipeline(for: identity) + + for path in fixture.mustFind { + let expanded = NSString(string: path).expandingTildeInPath + #expect(result.related.contains { $0.artifact.url.path.hasSuffix(expanded) } || + result.developer.contains { $0.artifact.url.path.hasSuffix(expanded) }, + "Must find: \(path)") + } + + for pattern in fixture.mustNotFind { + #expect((result.related + result.developer).allSatisfy { !$0.artifact.url.path.contains(pattern) }, + "Must not find: \(pattern)") + } + } +} + +enum TestError: Error { + case fixtureNotFound(String) +} + +extension Tag { + @Tag static var baseline: Tag +} diff --git a/MacOSCleaner/MacOSCleanerTests/Rules/ApplicationRuleRegistryTests.swift b/MacOSCleaner/MacOSCleanerTests/Rules/ApplicationRuleRegistryTests.swift new file mode 100644 index 0000000..0b0cf30 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/Rules/ApplicationRuleRegistryTests.swift @@ -0,0 +1,287 @@ +import XCTest +@testable import MacOSCleaner + +final class ApplicationRuleRegistryTests: XCTestCase { + func test_registry_returns_defaultRule_for_unknown_app() async { + let registry = ApplicationRuleRegistry() + let identity = AppIdentity( + bundleID: "com.unknown.app", + appName: "Unknown", + bundleName: nil, bundleVersion: nil, + executableName: "Unknown", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Unknown.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Generic") + } + + func test_registry_returns_electronRule_for_postman() async { + let registry = ApplicationRuleRegistry() + await registry.registerAll([ + ElectronRule(), BrowserRule(), JetBrainsRule(), + DockerRule(), XcodeRule(), AndroidStudioRule(), + ]) + let identity = AppIdentity( + bundleID: "com.postmanlabs.mac", + appName: "Postman", + bundleName: nil, bundleVersion: nil, + executableName: "Postman", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Postman.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Postman"], helperNames: [], frameworkNames: ["Electron Framework"], + xpcServiceNames: [], plugInNames: [], + isElectron: true, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "Electron") + } + + func test_registry_returns_jetbrainsRule_for_intellij() async { + let registry = ApplicationRuleRegistry() + await registry.registerAll([ + ElectronRule(), BrowserRule(), JetBrainsRule(), + DockerRule(), XcodeRule(), AndroidStudioRule(), + ]) + let identity = AppIdentity( + bundleID: "com.jetbrains.intellij", + appName: "IntelliJ IDEA", + bundleName: "IntelliJ IDEA", bundleVersion: nil, + executableName: "idea", + teamID: "2YEDZK7QJ8", signingAuthority: "Developer ID Application: JetBrains", + bundleURL: URL(fileURLWithPath: "/Applications/IntelliJ IDEA.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["JetBrains", "IntelliJ"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: true, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let rule = await registry.bestRule(for: identity) + XCTAssertEqual(rule.displayName, "JetBrains") + } +} + +final class ElectronRuleTests: XCTestCase { + func test_matches_postman() { + let rule = ElectronRule() + let identity = AppIdentity( + bundleID: "com.postmanlabs.mac", + appName: "Postman", + bundleName: nil, bundleVersion: nil, + executableName: "Postman", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Postman.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: ["Electron Framework"], + xpcServiceNames: [], plugInNames: [], + isElectron: true, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_evidence_for_electron_cache() { + let rule = ElectronRule() + let identity = AppIdentity( + bundleID: "com.postmanlabs.mac", + appName: "Postman", + bundleName: nil, bundleVersion: nil, + executableName: "Postman", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Postman.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: true, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/Postman/Cache") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .rule }) + } +} + +final class BrowserRuleTests: XCTestCase { + func test_matches_chrome() { + let rule = BrowserRule() + let identity = AppIdentity( + bundleID: "com.google.Chrome", + appName: "Google Chrome", + bundleName: nil, bundleVersion: nil, + executableName: "Google Chrome", + teamID: "EQHXZ8M8AV", signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Google Chrome.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Google"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_evidence_for_chrome_cache() { + let rule = BrowserRule() + let identity = AppIdentity( + bundleID: "com.google.Chrome", + appName: "Google Chrome", + bundleName: nil, bundleVersion: nil, + executableName: "Google Chrome", + teamID: "EQHXZ8M8AV", signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Google Chrome.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Caches/Google Chrome/Default/Cache") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .appName }) + } +} + +final class DockerRuleTests: XCTestCase { + func test_matches_docker() { + let rule = DockerRule() + let identity = AppIdentity( + bundleID: "com.docker.docker", + appName: "Docker", + bundleName: nil, bundleVersion: nil, + executableName: "Docker", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Docker.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Docker"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: true + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_evidence_for_docker_container() { + let rule = DockerRule() + let identity = AppIdentity( + bundleID: "com.docker.docker", + appName: "Docker", + bundleName: nil, bundleVersion: nil, + executableName: "Docker", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Docker.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: true + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Containers/com.docker.docker") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .bundleID }) + } +} + +final class JetBrainsRuleTests: XCTestCase { + func test_matches_intellij() { + let rule = JetBrainsRule() + let identity = AppIdentity( + bundleID: "com.jetbrains.intellij", + appName: "IntelliJ IDEA", + bundleName: nil, bundleVersion: nil, + executableName: "idea", + teamID: "2YEDZK7QJ8", signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/IntelliJ IDEA.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["JetBrains"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: true, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_does_not_match_non_jetbrains() { + let rule = JetBrainsRule() + let identity = AppIdentity( + bundleID: "com.postmanlabs.mac", + appName: "Postman", + bundleName: nil, bundleVersion: nil, + executableName: "Postman", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Postman.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertFalse(rule.matches(identity: identity)) + } +} + +final class XcodeRuleTests: XCTestCase { + func test_matches_xcode() { + let rule = XcodeRule() + let identity = AppIdentity( + bundleID: "com.apple.dt.Xcode", + appName: "Xcode", + bundleName: nil, bundleVersion: nil, + executableName: "Xcode", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Xcode.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertTrue(rule.matches(identity: identity)) + } + + func test_evidence_for_xcode_container() { + let rule = XcodeRule() + let identity = AppIdentity( + bundleID: "com.apple.dt.Xcode", + appName: "Xcode", + bundleName: nil, bundleVersion: nil, + executableName: "Xcode", + teamID: nil, signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Xcode.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Containers/com.apple.dt.Xcode") + let evidence = rule.evidence(for: url, identity: identity) + XCTAssertTrue(evidence.contains { $0.source == .bundleID }) + } +} + +final class AndroidStudioRuleTests: XCTestCase { + func test_matches_android_studio() { + let rule = AndroidStudioRule() + let identity = AppIdentity( + bundleID: "com.google.android.studio", + appName: "Android Studio", + bundleName: nil, bundleVersion: nil, + executableName: "studio", + teamID: "EQHXZ8M8AV", signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Android Studio.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Google"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + XCTAssertTrue(rule.matches(identity: identity)) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/SnapshotStoreTests.swift b/MacOSCleaner/MacOSCleanerTests/SnapshotStoreTests.swift new file mode 100644 index 0000000..278813f --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/SnapshotStoreTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import MacOSCleaner + +final class SnapshotStoreTests: XCTestCase { + var testRoot: URL! + var store: SnapshotStore! + + override func setUp() { + super.setUp() + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("SnapshotStoreTests_\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + store = SnapshotStore(storageURL: testRoot) + } + + override func tearDown() { + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + super.tearDown() + } + + func testSaveAndLoad() async throws { + let snapshot = UninstallSnapshot( + appName: "TestApp", + bundleID: "com.test.app", + appVersion: "1.0", + appBundlePath: "/Applications/TestApp.app", + deletedPaths: [ + "/Applications/TestApp.app", + "~/Library/Application Support/TestApp", + "~/Library/Caches/com.test.app", + ], + bypassTrash: false + ) + + try await store.save(snapshot: snapshot) + + let loaded = try await store.load(id: snapshot.id) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.appName, "TestApp") + XCTAssertEqual(loaded?.bundleID, "com.test.app") + XCTAssertEqual(loaded?.appVersion, "1.0") + XCTAssertEqual(loaded?.deletedPaths.count, 3) + XCTAssertFalse(loaded?.bypassTrash ?? true) + } + + func testList_returnsSnapshotsInReverseChronologicalOrder() async throws { + let earlier = UninstallSnapshot( + id: UUID(), + timestamp: Date().addingTimeInterval(-3600), + appName: "OldApp", + bundleID: "com.old.app", + appVersion: nil, + appBundlePath: "/Applications/OldApp.app", + deletedPaths: ["/Applications/OldApp.app"], + bypassTrash: false + ) + let later = UninstallSnapshot( + id: UUID(), + timestamp: Date(), + appName: "NewApp", + bundleID: "com.new.app", + appVersion: "2.0", + appBundlePath: "/Applications/NewApp.app", + deletedPaths: ["/Applications/NewApp.app"], + bypassTrash: true + ) + + try await store.save(snapshot: earlier) + try await store.save(snapshot: later) + + let list = try await store.list() + XCTAssertEqual(list.count, 2) + XCTAssertEqual(list[0].appName, "NewApp") + XCTAssertEqual(list[1].appName, "OldApp") + } + + func testDelete_removesSnapshot() async throws { + let snapshot = UninstallSnapshot( + appName: "TestApp", + bundleID: "com.test.app", + appVersion: nil, + appBundlePath: "/Applications/TestApp.app", + deletedPaths: ["/Applications/TestApp.app"], + bypassTrash: false + ) + + try await store.save(snapshot: snapshot) + var count = try await store.snapshotCount() + XCTAssertEqual(count, 1) + + try await store.delete(id: snapshot.id) + count = try await store.snapshotCount() + XCTAssertEqual(count, 0) + let loaded = try await store.load(id: snapshot.id) + XCTAssertNil(loaded) + } + + func testLoad_nonexistentId_returnsNil() async throws { + let loaded = try await store.load(id: UUID()) + XCTAssertNil(loaded) + } + + func testList_emptyStore_returnsEmptyArray() async throws { + let list = try await store.list() + XCTAssertTrue(list.isEmpty) + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift b/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift index 872f3f3..848969d 100644 --- a/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift @@ -3,59 +3,71 @@ import XCTest final class UninstallerServiceTests: XCTestCase { var service: UninstallerService! - + override func setUp() async throws { service = UninstallerService() } - - func testCreateSearchPatternsXcode() async { - let patterns = await service.createSearchPatterns(bundleID: "com.apple.dt.Xcode", appName: "Xcode") - let set = Set(patterns) - - XCTAssertTrue(set.contains("com.apple.dt.Xcode")) - XCTAssertTrue(set.contains("apple.dt.Xcode")) - XCTAssertTrue(set.contains("dt.Xcode")) - XCTAssertTrue(set.contains("Xcode")) - XCTAssertTrue(set.contains("Simulator")) - XCTAssertTrue(set.contains("Instruments")) + + func testScanState_initiallyDiscovered() { + let app = UninstallerService.AppInfo( + url: URL(fileURLWithPath: "/Applications/Test.app"), + bundleID: "com.test.app", + name: "TestApp" + ) + XCTAssertEqual(app.scanState, .discovered) + } + + func testAppInfo_totalSize_withRelatedFiles() { + var app = UninstallerService.AppInfo( + url: URL(fileURLWithPath: "/Applications/Test.app"), + bundleID: "com.test.app", + name: "TestApp", + size: 100 + ) + app.relatedFiles = [ + UninstallerService.RelatedFile(url: URL(fileURLWithPath: "/Library/Caches/test.cache"), size: 50), + UninstallerService.RelatedFile(url: URL(fileURLWithPath: "/Library/Preferences/test.plist"), size: 30), + ] + XCTAssertEqual(app.totalSize, 180) } - - func testCreateSearchPatternsAndroidStudio() async { - let patterns = await service.createSearchPatterns(bundleID: "com.google.android.studio", appName: "Android Studio") - let set = Set(patterns) - - XCTAssertTrue(set.contains("com.google.android.studio")) - XCTAssertTrue(set.contains("google.android.studio")) - XCTAssertTrue(set.contains("android.studio")) - XCTAssertTrue(set.contains("studio")) - XCTAssertTrue(set.contains("AndroidStudio")) - XCTAssertTrue(set.contains("Android")) - XCTAssertTrue(set.contains("Studio")) - XCTAssertTrue(set.contains("gradle")) - XCTAssertTrue(set.contains("emulator")) + + func testAppInfo_totalSize_ignoresDeselected() { + var app = UninstallerService.AppInfo( + url: URL(fileURLWithPath: "/Applications/Test.app"), + bundleID: "com.test.app", + name: "TestApp", + size: 100 + ) + app.relatedFiles = [ + UninstallerService.RelatedFile(url: URL(fileURLWithPath: "/Library/Caches/test.cache"), isSelected: false, size: 50), + UninstallerService.RelatedFile(url: URL(fileURLWithPath: "/Library/Preferences/test.plist"), size: 30), + ] + XCTAssertEqual(app.totalSize, 130) } - - func testCreateSearchPatternsFlutter() async { - let patterns = await service.createSearchPatterns(bundleID: "com.apple.mobileinstallation", appName: "Runner") - let set = Set(patterns) - - // Wait, appName is Runner, but maybe we should use a better name. - // If we search for Runner, we should find related stuff. - XCTAssertTrue(set.contains("Runner")) + + func testConfidenceTier_comparable() { + XCTAssertTrue(ConfidenceTier.possible < ConfidenceTier.veryLikely) + XCTAssertTrue(ConfidenceTier.veryLikely < ConfidenceTier.guaranteed) + XCTAssertTrue(ConfidenceTier.ignore < ConfidenceTier.possible) } - - func testCreateSearchPatternsMacOSCleaner() async { - let patterns = await service.createSearchPatterns(bundleID: "input.MacOSCleaner", appName: "MacOSCleaner") - let set = Set(patterns) - - XCTAssertTrue(set.contains("input.MacOSCleaner")) - XCTAssertTrue(set.contains("MacOSCleaner")) - XCTAssertTrue(set.contains("macoscleaner")) + + func testRelatedFile_evidenceAndConfidence() { + let file = UninstallerService.RelatedFile( + url: URL(fileURLWithPath: "/Library/Caches/test.cache"), + evidence: [.bundleIDExact, .teamID], + confidence: .guaranteed + ) + XCTAssertTrue(file.evidence.contains(.bundleIDExact)) + XCTAssertEqual(file.confidence, .guaranteed) } - - func testSystemSearchPaths() async { - let paths = await service.getSystemSearchPaths() - XCTAssertFalse(paths.isEmpty) - XCTAssertTrue(paths.contains { $0.contains("/var/folders/") }) + + func testEvidence_category_mapping() { + XCTAssertEqual(Evidence.bundleIDExact.category, .identity) + XCTAssertEqual(Evidence.teamID.category, .signature) + XCTAssertEqual(Evidence.launchAgent.category, .system) + XCTAssertEqual(Evidence.packageReceipt.category, .metadata) + XCTAssertEqual(Evidence.spotlight.category, .content) + XCTAssertEqual(Evidence.parentDirectory.category, .graph) + XCTAssertEqual(Evidence.launchServicesRegistered.category, .launchServices) } } diff --git a/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift new file mode 100644 index 0000000..7a9e22a --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import MacOSCleaner + +final class VerificationEngineTests: XCTestCase { + var testRoot: URL! + var mockRunner: MockCommandRunner! + var plistCache: PlistContentCache! + var codesignCache: CodesignCache! + + override func setUp() { + super.setUp() + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("VerificationEngineTests_\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + + mockRunner = MockCommandRunner() + plistCache = PlistContentCache() + codesignCache = CodesignCache() + } + + override func tearDown() { + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + super.tearDown() + } + + func testVerify_noLeftovers_returnsZero() async { + mockRunner.mockMdfind(query: "com.test.nonexistent", results: []) + mockRunner.mockPkgutil(bundleID: "com.test.nonexistent", files: []) + + let identity = AppIdentity( + bundleID: "com.test.nonexistent", + appName: "TestNonexistent", + bundleName: nil, + bundleVersion: nil, + executableName: "TestNonexistent", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/TestNonexistent.app"), + isAppStore: false, + isSandboxed: false, + isAdHocSigned: false, + vendorNames: [], + helperNames: [], + frameworkNames: [], + xpcServiceNames: [], + plugInNames: [], + isElectron: false, + isJetBrains: false, + isFlutter: false, + isJava: false, + isQt: false, + isDocker: false + ) + + let engine = VerificationEngine( + commandRunner: mockRunner, + codesignCache: codesignCache, + plistCache: plistCache + ) + + let report = await engine.verify(identity: identity) + XCTAssertFalse(report.hasLeftovers) + XCTAssertEqual(report.count, 0) + XCTAssertTrue(report.leftovers.isEmpty) + } + + func testVerify_withRealDir_detectsLeftover() async { + // Create a directory in ~/Library/Application Support/ so CandidateCollector + // finds it via file system scan (no mdfind mocking needed). + let appName = "TestApp_\(UUID().uuidString)" + let supportDir = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Application Support") + .appendingPathComponent(appName) + try? FileManager.default.createDirectory(at: supportDir, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: supportDir) } + + // The directory name matches identity.appName, so EvidenceProbe will + // produce .appNameExact (weight 60) which exceeds .veryLikely threshold. + let identity = AppIdentity( + bundleID: "com.test.\(appName)", + appName: appName, + bundleName: nil, + bundleVersion: nil, + executableName: appName, + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(appName).app"), + isAppStore: false, + isSandboxed: false, + isAdHocSigned: false, + vendorNames: [], + helperNames: [], + frameworkNames: [], + xpcServiceNames: [], + plugInNames: [], + isElectron: false, + isJetBrains: false, + isFlutter: false, + isJava: false, + isQt: false, + isDocker: false + ) + + let engine = VerificationEngine( + codesignCache: codesignCache, + plistCache: plistCache + ) + + let report = await engine.verify(identity: identity) + XCTAssertTrue(report.hasLeftovers, "Should detect leftover directory in ~/Library/Application Support/") + XCTAssertGreaterThan(report.count, 0) + } +} diff --git a/MacOSCleaner/Models/OperationRisk.swift b/MacOSCleaner/Models/OperationRisk.swift index 46a4f05..393c27b 100644 --- a/MacOSCleaner/Models/OperationRisk.swift +++ b/MacOSCleaner/Models/OperationRisk.swift @@ -5,4 +5,8 @@ public enum OperationRisk: String, Codable, Sendable { case moderate case dangerous case protected + + public var localizedTitle: String { + "risk.\(rawValue)".localized + } } diff --git a/MacOSCleaner/Models/ProcessGroup.swift b/MacOSCleaner/Models/ProcessGroup.swift index e074d46..98e39d6 100644 --- a/MacOSCleaner/Models/ProcessGroup.swift +++ b/MacOSCleaner/Models/ProcessGroup.swift @@ -30,11 +30,11 @@ public struct ProcessGroup: Identifiable, Sendable { } public var totalMemoryFormatted: String { - ByteCountFormatter.string(fromByteCount: Int64(totalMemory), countStyle: .memory) + ByteCountFormatter.localizedString(fromByteCount: Int64(totalMemory), countStyle: .memory) } public var totalCPUFormatted: String { - String(format: "%.1f%%", totalCPU) + String(format: "process_cpu_format".localized, totalCPU) } public var processCount: Int { @@ -101,7 +101,7 @@ public struct ProcessGroup: Identifiable, Sendable { return first.name } - return "Unknown" + return "process.unknown".localized } private static func extractIcon(from processes: [RunningProcess]) -> String { diff --git a/MacOSCleaner/Models/RunningProcess.swift b/MacOSCleaner/Models/RunningProcess.swift index cc8b597..3a60fa2 100644 --- a/MacOSCleaner/Models/RunningProcess.swift +++ b/MacOSCleaner/Models/RunningProcess.swift @@ -47,11 +47,11 @@ public struct RunningProcess: Identifiable, Sendable, Hashable { } public var memoryFormatted: String { - ByteCountFormatter.string(fromByteCount: Int64(memoryBytes), countStyle: .memory) + ByteCountFormatter.localizedString(fromByteCount: Int64(memoryBytes), countStyle: .memory) } public var cpuFormatted: String { - String(format: "%.1f%%", cpuPercent) + String(format: "process_cpu_format".localized, cpuPercent) } public var uptime: TimeInterval? { @@ -64,9 +64,9 @@ public struct RunningProcess: Identifiable, Sendable, Hashable { let hours = Int(uptime) / 3600 let minutes = (Int(uptime) % 3600) / 60 if hours > 0 { - return "\(hours)h \(minutes)m" + return String(format: "process_uptime_hours_format".localized, hours, minutes) } - return "\(minutes)m" + return String(format: "process_uptime_minutes_format".localized, minutes) } public var category: ProcessCategory { @@ -111,4 +111,13 @@ public enum ProcessCategory: String, CaseIterable, Sendable { case launchAgents = "Launch Agents" case launchDaemons = "Launch Daemons" case system = "System" + + public var localizedTitle: String { + switch self { + case .applications: return "process.category.applications".localized + case .launchAgents: return "process.category.launch_agents".localized + case .launchDaemons: return "process.category.launch_daemons".localized + case .system: return "process.category.system".localized + } + } } diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 7541a94..81430b2 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -48,6 +48,12 @@ "dashboard_recent_operations" = "Recent Operations"; "dashboard_no_recent_operations" = "No recent operations"; +// Language Names +"language.english" = "English"; +"language.russian" = "Russian"; +"language.ukrainian" = "Ukrainian"; +"language.spanish" = "Spanish"; + // Settings View "settings_title" = "Settings"; "settings_subtitle" = "Configure app preferences"; @@ -195,6 +201,8 @@ // Trash "trash_user_label" = "User Recycle Bin"; "trash_user_description" = "Contents of your system Recycle Bin. Contains deleted files."; +"trash_access_prompt_message" = "Please select the Trash folder to grant access for scanning and cleaning. (It is already selected, just click 'Grant Access')"; +"trash_access_prompt_button" = "Grant Access"; // Uninstaller View "uninstaller_title" = "Uninstaller"; @@ -224,6 +232,8 @@ "uninstaller_expert_tip" = "In Expert Mode you can selectively remove leftovers like caches and preferences."; "uninstaller_cleanup_items" = "Cleanup Items"; "uninstaller_scanning_apps" = "Scanning Applications..."; +"uninstaller.deep_scanning_progress" = "Scanning leftovers: %d of %d apps..."; +"uninstaller.analyzing" = "Analyzing..."; "uninstaller_complete_title" = "Uninstallation Complete"; "uninstaller_complete_body" = "Application %@ was successfully removed."; "shared_data_warning" = "This data is shared with other apps (e.g., Android SDK, AVDs). Deleting may affect other IDEs."; @@ -284,6 +294,30 @@ "settings_open_settings" = "Open Settings"; "settings_check_permissions" = "Check Permissions"; +// Format strings +"dashboard_used_percent_format" = "%lld%%"; +"dashboard_freed_prefix" = "+%@"; +"cleanup_mb_format" = "%lld MB"; + +// Process format strings +"processes_view_mode_grouped" = "Grouped"; +"processes_view_mode_flat" = "Flat"; +"processes_selected_count" = "%lld selected"; +"processes_process_count" = "%lld processes"; +"process_pid_format" = "PID %lld"; +"process_cpu_format" = "%.1f%%"; +"process_uptime_hours_format" = "%lldh %lldm"; +"process_uptime_minutes_format" = "%lldm"; + +// Common +"version_unknown" = "N/A"; + +// Operation Risk +"risk.safe" = "Safe"; +"risk.moderate" = "Moderate"; +"risk.dangerous" = "Dangerous"; +"risk.protected" = "Protected"; + // Cleanup Dev Badge "cleanup_dev_badge" = "DEV"; @@ -299,17 +333,211 @@ "sort_name" = "Name"; "sort_threads" = "Thread Count"; -// New cleanup categories -"time_machine_snapshots" = "Time Machine Snapshots"; -"ios_backups" = "iOS Backups"; -"mail_downloads" = "Mail Downloads"; -"saved_app_state" = "Saved Application State"; -"crash_reporter" = "Crash Reporter"; -"assets_v2" = "AssetsV2 / iWork Templates"; -"cloud_kit_cache" = "iCloud CloudKit Cache"; -"swift_pm_cache" = "Swift Package Manager Cache"; -"carthage_cache" = "Carthage Cache"; -"steam_cache" = "Steam Cache"; -"teams_cache" = "Microsoft Teams Cache"; -"adobe_caches" = "Adobe Caches"; -"chrome_extra_caches" = "Chrome Extra Caches"; +// Cleanup Category Names +"category.app_caches" = "User app caches"; +"category.package_managers" = "Package managers"; +"category.gradle_maven" = "Gradle + Maven"; +"category.flutter_dart" = "Flutter / Dart"; +"category.xcode" = "Xcode"; +"category.ios_simulators" = "iOS Simulators"; +"category.android_caches" = "Android caches"; +"category.android_sdk" = "Android SDK"; +"category.ide_caches" = "IDE / Electron caches"; +"category.browser_caches" = "Browser caches"; +"category.messaging_media" = "Messaging / media"; +"category.docker" = "Docker"; +"category.language_caches" = "Language caches"; +"category.user_logs" = "User logs"; +"category.system_caches" = "System caches"; +"category.app_containers" = "App containers"; +"category.dotfile_caches" = "Dotfile caches"; +"category.scattered_junk" = "Scattered junk"; +"category.orphaned_remnants" = "Orphaned remnants"; +"category.orphaned_files" = "Orphaned files"; +"category.large_files" = "Large files"; +"category.dynamic_cache_discovery" = "Dynamic cache discovery"; +"category.time_machine_snapshots" = "Time Machine Snapshots"; +"category.ios_backups" = "iOS Backups"; +"category.mail_downloads" = "Mail Downloads"; +"category.saved_app_state" = "Saved Application State"; +"category.crash_reporter" = "Crash Reporter"; +"category.assets_v2" = "AssetsV2 / iWork Templates"; +"category.cloud_kit_cache" = "iCloud CloudKit Cache"; +"category.swift_pm_cache" = "Swift Package Manager Cache"; +"category.carthage_cache" = "Carthage Cache"; +"category.steam_cache" = "Steam Cache"; +"category.teams_cache" = "Microsoft Teams Cache"; +"category.adobe_caches" = "Adobe Caches"; +"category.chrome_extra_caches" = "Chrome Extra Caches"; +"category.ide_old_versions" = "Old IDE Versions"; +"category.launch_agents" = "Launch Agents"; +"category.launch_daemons" = "Launch Daemons"; +"category.privileged_helpers" = "Privileged Helper Tools"; +"category.pkg_receipts" = "Package Receipts"; +"category.internet_plugins" = "Internet Plugins"; +"category.shared_file_lists" = "Shared File Lists"; +"category.cloud_docs" = "Cloud Docs"; +"category.photos_cache" = "Photos Cache"; +"category.voice_memos" = "Voice Memos"; +"category.garage_band_logic" = "GarageBand / Logic Pro"; +"category.imovie_final_cut" = "iMovie / Final Cut"; +"category.garmin_fitbit" = "Garmin / Fitbit"; +"category.old_backups" = "Old Backups"; +"category.dns_flush" = "DNS Cache"; +"category.font_cache" = "Font Cache"; +"category.sleep_image" = "Sleep Image"; +"category.duplicate_files" = "Duplicate Files"; +"category.unused_apps" = "Unused Apps"; + +// Process View - Missing Keys +"view_mode" = "View Mode"; +"sort_by" = "Sort By"; +"cancel_selection" = "Cancel Selection"; +"select_multiple" = "Select Multiple"; +"select_all" = "Select All"; +"deselect_all" = "Deselect All"; +"terminate_selected" = "Terminate Selected"; +"force_kill_selected" = "Force Kill Selected"; +"processes_terminate_all" = "Terminate All"; +"processes_force_kill_all" = "Force Kill All"; +"process.unknown" = "Unknown"; + +// Uninstaller - Scan Progress +"uninstaller.progress.discovering" = "Discovering applications..."; +"uninstaller.progress.complete" = "Scan complete"; + +// Uninstaller - Confidence Tiers +"uninstaller.tier.ignore" = "Ignore"; +"uninstaller.tier.possible" = "Possible"; +"uninstaller.tier.very_likely" = "Very Likely"; +"uninstaller.tier.guaranteed" = "Guaranteed"; + +// Developer Components +"developer.android_sdk" = "Android SDK"; +"developer.gradle_cache" = "Gradle Cache"; +"developer.xcode_derived_data" = "Xcode Derived Data"; +"developer.ios_simulators" = "iOS Simulators"; +"developer.flutter_cache" = "Flutter Cache"; +"developer.docker" = "Docker"; +"developer.homebrew" = "Homebrew"; + +// Evidence Categories +"uninstaller.evidence_category.identity" = "Identity Match"; +"uninstaller.evidence_category.signature" = "Code Signature"; +"uninstaller.evidence_category.system" = "System Integration"; +"uninstaller.evidence_category.metadata" = "File Metadata"; +"uninstaller.evidence_category.content" = "Content Analysis"; +"uninstaller.evidence_category.graph" = "Graph Propagation"; +"uninstaller.evidence_category.launch_services" = "Launch Services"; + +// Evidence Descriptions +"uninstaller.evidence.bundleIDExact.title" = "Bundle ID Matches"; +"uninstaller.evidence.bundleIDExact.description" = "Name matches the app's bundle identifier."; +"uninstaller.evidence.bundleIDPrefix.title" = "Bundle ID Prefix"; +"uninstaller.evidence.bundleIDPrefix.description" = "Name starts with '%@'."; +"uninstaller.evidence.appNameExact.title" = "App Name Matches"; +"uninstaller.evidence.appNameExact.description" = "Name matches the application name."; +"uninstaller.evidence.appNamePrefix.title" = "App Name Prefix"; +"uninstaller.evidence.appNamePrefix.description" = "Name starts with the application name."; +"uninstaller.evidence.executableName.title" = "Executable Name"; +"uninstaller.evidence.executableName.description" = "Name matches the app's executable."; +"uninstaller.evidence.frameworkName.title" = "Framework Name"; +"uninstaller.evidence.frameworkName.description" = "File is a framework used by the app."; +"uninstaller.evidence.xpcServiceName.title" = "XPC Service"; +"uninstaller.evidence.xpcServiceName.description" = "File is an XPC service used by the app."; +"uninstaller.evidence.plugInName.title" = "Plug-in Name"; +"uninstaller.evidence.plugInName.description" = "File is a plug-in used by the app."; +"uninstaller.evidence.vendorName.title" = "Vendor Name"; +"uninstaller.evidence.vendorName.description" = "File belongs to the same vendor."; +"uninstaller.evidence.teamID.title" = "Team ID Match"; +"uninstaller.evidence.teamID.description" = "Signed by team %@, matching the application."; +"uninstaller.evidence.developerSignature.title" = "Developer Signature"; +"uninstaller.evidence.developerSignature.description" = "Signed by the same developer certificate."; +"uninstaller.evidence.launchAgent.title" = "Launch Agent"; +"uninstaller.evidence.launchAgent.description" = "A launch agent registered by the app."; +"uninstaller.evidence.launchDaemon.title" = "Launch Daemon"; +"uninstaller.evidence.launchDaemon.description" = "A launch daemon registered by the app."; +"uninstaller.evidence.loginItem.title" = "Login Item"; +"uninstaller.evidence.loginItem.description" = "A login item registered by the app."; +"uninstaller.evidence.appGroup.title" = "App Group"; +"uninstaller.evidence.appGroup.description" = "Belongs to the app's group container."; +"uninstaller.evidence.container.title" = "App Container"; +"uninstaller.evidence.container.description" = "Application sandbox container."; +"uninstaller.evidence.extension.title" = "App Extension"; +"uninstaller.evidence.extension.description" = "App extension registered by the app."; +"uninstaller.evidence.xpcConnection.title" = "XPC Connection"; +"uninstaller.evidence.xpcConnection.description" = "An XPC connection used by the app."; +"uninstaller.evidence.packageReceipt.title" = "Package Receipt"; +"uninstaller.evidence.packageReceipt.description" = "Registered via a package receipt."; +"uninstaller.evidence.plistContent.title" = "Plist Content"; +"uninstaller.evidence.plistContent.description" = "Property list contains the app's name or bundle ID."; +"uninstaller.evidence.spotlight.title" = "Spotlight Index"; +"uninstaller.evidence.spotlight.description" = "Found via Spotlight search."; +"uninstaller.evidence.spotlightBundleAttr.title" = "Spotlight Bundle Attribute"; +"uninstaller.evidence.spotlightBundleAttr.description" = "Spotlight metadata reports bundle identifier '%@'."; +"uninstaller.evidence.spotlightCreator.title" = "Spotlight Creator"; +"uninstaller.evidence.spotlightCreator.description" = "Spotlight creator metadata matches."; +"uninstaller.evidence.fileContent.title" = "File Content"; +"uninstaller.evidence.fileContent.description" = "File content references the app."; +"uninstaller.evidence.electronCache.title" = "Electron Cache"; +"uninstaller.evidence.electronCache.description" = "Electron-based app cache."; +"uninstaller.evidence.jetBrainsConfig.title" = "JetBrains Config"; +"uninstaller.evidence.jetBrainsConfig.description" = "JetBrains IDE configuration."; +"uninstaller.evidence.flutterBuild.title" = "Flutter Build"; +"uninstaller.evidence.flutterBuild.description" = "Flutter build artifact."; +"uninstaller.evidence.parentDirectory.title" = "Parent Directory"; +"uninstaller.evidence.parentDirectory.description" = "Found in a directory related to the app."; +"uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; +"uninstaller.evidence.launchServicesRegistered.description" = "Registered in the Launch Services database."; + +// Permissions - Missing Access Descriptions +"permissions.full_disk_access" = "Full Disk Access"; +"permissions.accessibility" = "Accessibility"; +"permissions.automation" = "Automation (Apple Events)"; +"permissions.trash_access" = "Trash Access"; +"permissions.notification_provisional" = "Provisional"; +"permissions.notification_ephemeral" = "Ephemeral"; +"permissions.unknown_status" = "Unknown"; + +// Process Categories +"process.category.applications" = "Applications"; +"process.category.launch_agents" = "Launch Agents"; +"process.category.launch_daemons" = "Launch Daemons"; +"process.category.system" = "System"; + +// Uninstaller - Additional UI +"uninstaller.scanning_deep" = "Deep Scanning..."; +"uninstaller.why_this_file" = "Why this file?"; +"uninstaller.related_files" = "Related Files"; +"uninstaller.developer_artifacts" = "Developer Artifacts"; +"uninstaller.progress.developer_components" = "Checking developer components..."; +"uninstaller.footer.summary" = "%lld file(s) selected across %@ tiers"; + +// Format Helpers +"format_bytes_b" = "%lld B"; +"format_bytes_kb" = "%.1f KB"; +"format_bytes_mb" = "%.1f MB"; +"format_bytes_gb" = "%.2f GB"; + +// Process Block Reasons +"process_block_pid_format" = "PID %lld is a system-critical process"; +"process_block_whitelist_name_format" = "%@ is in your whitelist (protected)"; +"process_block_whitelist_bundle_format" = "%@ is in your whitelist (protected)"; +"process_block_protected_format" = "%@ is a protected system process"; +"process_block_no_path_format" = "%@ has no path info — proceed with caution"; + +// Error Messages +"error_ps_failed_format" = "Failed to list processes: %@"; +"error_operation_blocked_format" = "Cannot terminate %@: %@"; +"error_kill_failed_format" = "Failed to kill %@ (exit %lld): %@"; +"error_timeout" = "Operation timed out"; +"error_safety_violation_format" = "Safety violation: %@"; +"error_command_failed_format" = "Command failed: %@"; +"error_invalid_transition_format" = "Invalid transition from %@ to %@"; + +// OS Version +"os_version_format" = "macOS %lld.%lld.%lld"; + +// Uninstaller Tooltips +"uninstaller_show_in_finder" = "Show in Finder"; +"uninstaller_used_by" = "Used by %@"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..e77ffee --- /dev/null +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -0,0 +1,541 @@ +// Common +"welcome_msg" = "¡Bienvenido de nuevo!"; +"app_title" = "Limpiador"; +"sidebar_select_item" = "Seleccione un elemento de la barra lateral"; +"close" = "Cerrar"; +"cancel_description" = "La operación fue cancelada por el usuario."; +"reset" = "Restablecer"; +"cancel" = "Cancelar"; +"done" = "Hecho"; +"try_again" = "Intentar de nuevo"; +"error" = "Error"; +"version" = "Versión"; +"size" = "Tamaño"; +"last_used" = "Último uso"; + +// Sidebar / Navigation Menu +"menu_dashboard" = "Panel"; +"menu_cleanup" = "Limpieza"; +"menu_startup_services" = "Servicios de inicio"; +"menu_startup_vendors" = "Proveedores de inicio"; +"menu_uninstaller" = "Desinstalador"; +"menu_settings" = "Ajustes"; + +// About View +"about_title" = "Acerca de MacOS Cleaner"; +"about_version" = "Versión %@"; +"about_developer" = "Desarrollado por AlexTkDev"; +"about_problem_link" = "Si tiene un problema con la aplicación, avíseme aquí"; +"about_linkedin" = "Perfil de LinkedIn"; +"about_copyright" = "© 2026 AlexTkDev. Todos los derechos reservados."; + +// Dashboard View +"dashboard_title" = "Panel"; +"dashboard_system_info" = "Información del sistema"; +"dashboard_model" = "Modelo"; +"dashboard_os_version" = "Versión del SO"; +"dashboard_processor" = "Procesador"; +"dashboard_memory" = "Memoria"; +"dashboard_disk_usage" = "Uso del disco"; +"dashboard_used" = "Usado"; +"dashboard_free" = "Libre"; +"dashboard_total" = "Total"; +"dashboard_statistics" = "Estadísticas"; +"dashboard_total_freed" = "Total liberado"; +"dashboard_cleanups" = "Limpiezas"; +"dashboard_status" = "Estado"; +"dashboard_healthy" = "Saludable"; +"dashboard_recent_operations" = "Operaciones recientes"; +"dashboard_no_recent_operations" = "Sin operaciones recientes"; + +// Language Names +"language.english" = "Inglés"; +"language.russian" = "Ruso"; +"language.ukrainian" = "Ucraniano"; +"language.spanish" = "Español"; + +// Settings View +"settings_title" = "Ajustes"; +"settings_subtitle" = "Configurar preferencias de la aplicación"; +"settings_general" = "General"; +"settings_language" = "Idioma"; +"settings_theme" = "Tema"; +"theme_system" = "Sistema"; +"theme_light" = "Claro"; +"theme_dark" = "Oscuro"; +"settings_notifications" = "Notificaciones"; +"settings_tooltips" = "Información"; +"settings_auto_scan" = "Escaneo automático al inicio"; +"settings_processes" = "Procesos"; +"settings_refresh_interval" = "Intervalo de actualización"; +"settings_sort_by" = "Ordenar por"; +"settings_startup" = "Inicio"; +"settings_trash_deletion" = "Papelera y eliminación"; +"settings_empty_trash_during_cleanup" = "Vaciar papelera durante la limpieza"; +"settings_bypass_trash_on_uninstall" = "Omitir papelera al desinstalar"; +"settings_empty_trash_immediately" = "Vaciar papelera inmediatamente"; +"settings_advanced" = "Avanzado"; +"settings_show_related" = "Mostrar archivos asociados en el desinstalador"; +"settings_skip_expert" = "Omitir modo experto en el desinstalador"; +"settings_data" = "Datos"; +"settings_forget_everything" = "Olvidar todo"; +"settings_forget_description" = "Borrar todos los datos guardados y restablecer los ajustes a sus valores predeterminados."; +"settings_reset_button" = "Restablecer todos los ajustes"; + +// Settings Tooltips +"settings_tooltip_language" = "Seleccione el idioma de la interfaz de la aplicación."; + +// Notification Status +"settings_notifications_status" = "Estado de notificaciones"; +"settings_notifications_granted" = "Permitido"; +"settings_notifications_denied" = "Denegado (abra Ajustes del sistema)"; +"settings_notifications_not_determined" = "No solicitado"; +"settings_open_notification_settings" = "Abrir ajustes de notificaciones"; +"settings_tooltip_theme" = "Elija la apariencia visual de la aplicación. Sistema sigue la configuración de macOS."; +"settings_tooltip_notifications" = "Mostrar notificaciones del sistema cuando se completen escaneos y operaciones de limpieza."; +"settings_tooltip_tooltips" = "Mostrar descripciones útiles al pasar el cursor sobre los elementos de la interfaz."; +"settings_tooltip_auto_scan" = "Comenzar a escanear automáticamente elementos de limpieza al iniciar la aplicación."; +"settings_tooltip_refresh_interval" = "Con qué frecuencia actualizar la lista de procesos. Manual desactiva la actualización automática."; +"settings_tooltip_sort_by" = "Orden de clasificación predeterminado para la lista de procesos."; +"settings_tooltip_empty_trash" = "Cuando está activado, la Papelera del sistema se vaciará como parte del proceso de escaneo de limpieza. Desactivado por seguridad."; +"settings_tooltip_bypass_trash" = "Al desinstalar una aplicación y sus archivos asociados, elimínelos permanentemente en lugar de moverlos a la Papelera. ADVERTENCIA: Esto hace que la desinstalación sea irreversible."; +"settings_tooltip_show_related" = "Mostrar la lista de archivos asociados (cachés, preferencias, registros) encontrados para cada aplicación en la vista del Desinstalador."; +"settings_tooltip_empty_trash_immediately" = "Después de mover elementos a la Papelera, vaciarla inmediatamente. Esto hace que la operación de limpieza sea irreversible."; +"settings_tooltip_skip_expert" = "Omitir el paso de selección de archivos del Modo Experto y desinstalar inmediatamente las aplicaciones con todos los archivos asociados seleccionados."; +"settings_tooltip_forget" = "Eliminar todas las preferencias guardadas y restaurar los valores de fábrica."; + +// Settings Reset Dialog +"settings_reset_confirm_title" = "¿Restablecer todos los ajustes?"; +"settings_reset_confirm_button" = "Restablecer todo"; +"settings_reset_confirm_message" = "Esto borrará todos los datos guardados y restablecerá cada ajuste a su valor predeterminado. Esta acción no se puede deshacer."; + +// Trash & Deletion Warning +"settings_trash_warning" = "Estos ajustes hacen que la eliminación sea irreversible. Los archivos que omiten la Papelera o se vacían inmediatamente no se pueden recuperar."; + +// Startup Services View +"startup_title" = "Servicios de inicio"; +"startup_subtitle" = "Gestionar agentes que se inician automáticamente."; +"startup_refresh" = "Actualizar lista"; +"startup_scanning" = "Escaneando servicios..."; +"startup_no_agents" = "Sin agentes de inicio"; +"startup_no_agents_sub" = "No se encontraron agentes en ~/Library/LaunchAgents."; +"startup_scan_failed" = "Escaneo fallido"; +"startup_status_loaded" = "Cargado"; +"startup_status_unloaded" = "Descargado"; +"startup_disable" = "Deshabilitar"; +"startup_enable" = "Habilitar"; + +// Startup Categories +"startup_category_user" = "Mis servicios"; +"startup_category_third_party" = "Terceros"; +"startup_category_system" = "Sistema"; + +// Startup Filters +"startup_filter_all" = "Todos"; + +// Startup Category Help +"startup_help_user" = "Servicio de usuario de ~/Library/. Seguro de deshabilitar."; +"startup_help_third_party" = "Servicio de terceros de /Library/. Se puede deshabilitar con precaución."; +"startup_help_system" = "Servicio del sistema Apple. No se recomienda deshabilitar."; + +// Startup Vendor Settings +"settings_startup_vendors" = "Proveedores del sistema"; +"startup_vendors_title" = "Proveedores del sistema"; +"startup_vendors_description" = "Prefijos de etiqueta considerados como servicios del sistema."; +"startup_vendors_description_sub" = "Los servicios con estos prefijos se marcan como 'Sistema' y no se recomienda deshabilitarlos."; +"startup_vendors_current" = "Prefijos actuales"; +"startup_vendors_reset" = "Restablecer"; +"startup_vendors_empty" = "No hay prefijos añadidos"; +"startup_vendors_protected" = "Protegido"; +"startup_vendors_placeholder" = "com.vendor."; +"startup_vendors_error_no_dot" = "El prefijo debe contener un punto"; +"startup_vendors_error_duplicate" = "Este prefijo ya existe"; + +// Cleanup View +"cleanup_scanning" = "Escaneando sistema..."; +"cleanup_clean" = "El sistema está limpio"; +"cleanup_clean_sub" = "No se encontraron archivos innecesarios durante el escaneo."; +"cleanup_rescan" = "Volver a escanear"; +"cleanup_cleaning" = "Limpiando..."; +"cleanup_ready" = "Listo para limpiar"; +"cleanup_ready_sub" = "Escanee su sistema para encontrar cachés y archivos temporales seguros de eliminar."; +"cleanup_additional_options" = "Opciones de limpieza adicionales"; +"cleanup_option_ds_store" = "Limpiar archivos .DS_Store"; +"cleanup_option_ds_store_sub" = "Elimina archivos de metadatos generados por el sistema de los directorios."; +"cleanup_option_maven" = "Limpiar repositorio de Maven (~/.m2/repository)"; +"cleanup_option_maven_sub" = "Elimina dependencias de Maven descargadas. Se descargan de nuevo en la próxima compilación."; +"cleanup_option_modcache" = "Limpiar caché de módulos Go (GOMODCACHE)"; +"cleanup_option_modcache_sub" = "Elimina módulos de Go descargados. Se descargan de nuevo en la próxima compilación."; +"cleanup_option_projects" = "Limpiar .dart_tool en proyectos"; +"cleanup_option_projects_sub" = "Elimina cachés de proyectos Flutter/Dart en ~/Documents, ~/Projects, ~/Developer, ~/dev, ~/code, ~/repos."; +"cleanup_start_scan" = "Iniciar escaneo"; +"cleanup_failed" = "Limpieza fallida"; +"cleanup_failed_default" = "Ocurrió un error durante el proceso de limpieza."; +"cleanup_script_logs" = "Registros de script:"; +"cleanup_complete" = "Limpieza completa"; +"cleanup_complete_sub" = "Se liberaron %lld MB de espacio en disco."; +"cleanup_summary" = "Resumen de elementos eliminados"; +"cleanup_skipped" = "No se pudo limpiar"; +"cleanup_selected" = "Seleccionado: %lld MB"; +"cleanup_hide_logs" = "Ocultar registros"; +"cleanup_show_logs" = "Mostrar registros"; +"cleanup_copy" = "Copiar"; +"cleanup_copy_logs" = "Copiar registros"; +"cleanup_now" = "Limpiar ahora"; +"cleanup_manual_instructions" = "Instrucciones de limpieza manual"; +"cleanup_scan_results" = "Resultados del escaneo"; +"cleanup_scan_results_sub" = "Seleccione los elementos que desea eliminar y haga clic en 'Limpiar ahora'."; +"cleanup_recommended" = "Recomendado para eliminar"; +"cleanup_deselect_all" = "Deseleccionar todo"; +"cleanup_select_all" = "Seleccionar todo"; +"cleanup_show_all_count" = "Mostrar todo (%lld más)"; +"cleanup_debug_log" = "Registro de depuración (%lld líneas)"; + +// Cleanup Notifications +"cleanup_scan_complete_title" = "Escaneo completo"; +"cleanup_scan_complete_body" = "Se encontraron %lld MB de archivos para limpiar."; +"cleanup_emptying_trash" = "Vaciando papelera..."; +"cleanup_complete_title" = "Limpieza completa"; +"cleanup_complete_body" = "Se liberaron %lld MB."; + +// Trash +"trash_user_label" = "Papelera del usuario"; +"trash_user_description" = "Contenido de la Papelera del sistema. Contiene archivos eliminados."; +"trash_access_prompt_message" = "Seleccione la carpeta de la Papelera para conceder acceso para escanear y limpiar. (Ya está seleccionada, solo haga clic en 'Conceder acceso')."; +"trash_access_prompt_button" = "Conceder acceso"; + +// Uninstaller View +"uninstaller_title" = "Desinstalador"; +"uninstaller_search" = "Buscar aplicaciones"; +"uninstaller_reload" = "Recargar aplicaciones"; +"uninstaller_confirm_perm_delete" = "¿Eliminar permanentemente?"; +"uninstaller_confirm_move_trash" = "¿Mover a la papelera?"; +"uninstaller_delete_permanently" = "Eliminar permanentemente"; +"uninstaller_move_trash" = "Mover a la papelera"; +"uninstaller_uninstall_app_warning_perm" = "Esto eliminará permanentemente %@ y %lld archivos relacionados. Esta acción no se puede deshacer."; +"uninstaller_uninstall_app_warning_trash" = "Esto moverá %@ y %lld archivos relacionados a la Papelera."; +"uninstaller_drag_drop" = "Arrastre .app aquí para escanear"; +"uninstaller_or_select" = "O SELECCIONE DE LA LISTA"; +"uninstaller_unknown_bundle" = "ID de paquete desconocido"; +"uninstaller_expert_mode" = "Modo experto"; +"uninstaller_select_files" = "(seleccionar archivos relacionados)"; +"uninstaller_action_info_perm" = "Acción permanente"; +"uninstaller_action_info_perm_sub" = "Los archivos se eliminan permanentemente, sin pasar por la Papelera."; +"uninstaller_action_info_trash" = "Acción reversible"; +"uninstaller_action_info_trash_sub" = "Los archivos se mueven a la Papelera y se pueden restaurar."; +"uninstaller_space_reclaim" = "Espacio total a recuperar: %@"; +"uninstaller_button_uninstall" = "Desinstalar aplicación"; +"uninstaller_related_files_count" = "%lld archivos relacionados encontrados"; +"uninstaller_developer_components" = "Datos relacionados del desarrollador"; +"uninstaller_developer_components_description" = "Gestione estos datos en Smart Cleanup."; +"uninstaller_open_cleanup" = "Abrir Smart Cleanup"; +"uninstaller_used_by" = "Usado por: %@"; +"uninstaller_show_in_finder" = "Mostrar en Finder"; +"uninstaller_expert_tip" = "En el Modo Experto puede eliminar selectivamente archivos residuales como cachés y preferencias."; +"uninstaller_cleanup_items" = "Elementos de limpieza"; +"uninstaller_scanning_apps" = "Escaneando aplicaciones..."; +"uninstaller.deep_scanning_progress" = "Escaneando residuos: %d de %d aplicaciones..."; +"uninstaller.analyzing" = "Analizando..."; +"uninstaller_complete_title" = "Desinstalación completa"; +"uninstaller_complete_body" = "La aplicación %@ se eliminó correctamente."; +"shared_data_warning" = "Estos datos se comparten con otras aplicaciones (ej. Android SDK, AVD). Eliminarlos puede afectar a otros IDEs."; + +// Processes View +"menu_processes" = "Procesos"; +"processes_title" = "Procesos"; +"processes_subtitle" = "Gestionar procesos del sistema en ejecución."; +"processes_search" = "Buscar procesos..."; +"processes_scanning" = "Escaneando procesos..."; +"processes_terminate" = "Terminar"; +"processes_force_kill" = "Forzar cierre"; +"processes_protected" = "Protegido"; +"processes_refresh" = "Actualizar lista"; +"processes_confirm_terminate" = "¿Terminar proceso?"; +"processes_confirm_terminate_message" = "¿Está seguro de que desea terminar %@ (PID %lld)?"; +"processes_confirm_force" = "¿Forzar cierre?"; +"processes_confirm_force_message" = "Forzar el cierre puede causar pérdida de datos. ¿Finalizar %@ (PID %lld)?"; +"processes_no_results" = "No se encontraron procesos coincidentes."; +"processes_no_processes" = "No se encontraron procesos"; +"processes_no_processes_sub" = "No se detectaron procesos en ejecución."; +"processes_scan_failed" = "Escaneo fallido"; +"processes_manage_blacklist" = "Gestionar lista negra"; +"processes_manage_whitelist" = "Gestionar lista blanca"; +"processes_tooltip_blacklist" = "Procesos que siempre puede finalizar — añada aplicaciones pesadas como Docker o OrbStack aquí"; +"processes_tooltip_whitelist" = "Procesos que nunca se pueden finalizar — proteja aplicaciones críticas contra terminación accidental"; +"processes_tooltip_refresh" = "Actualizar la lista de procesos en ejecución"; +"processes_section_user" = "Sus procesos"; +"processes_section_system" = "Procesos del sistema"; +"processes_badge_blacklist" = "Lista negra (%lld)"; +"processes_badge_whitelist" = "Lista blanca (%lld)"; +"processes_blacklist_title" = "Lista negra"; +"processes_blacklist_placeholder" = "Nombre del proceso a bloquear..."; +"processes_whitelist_title" = "Lista blanca"; +"processes_whitelist_placeholder" = "Nombre del proceso a proteger..."; +"add" = "Añadir"; + +// Permissions +"permissions_title" = "Permisos requeridos"; +"permissions_subtitle" = "MacOSCleaner necesita acceso a las carpetas del sistema para la limpieza."; +"permissions_fda_description" = "Necesario para acceder a ~/Library/Caches, ~/Library/Application Support y otras carpetas del sistema."; +"permissions_instructions_title" = "Cómo conceder Acceso completo al disco:"; +"permissions_step1" = "Haga clic en 'Abrir Ajustes del sistema' abajo."; +"permissions_step2" = "Busque MacOSCleaner en la lista."; +"permissions_step3" = "Active el interruptor."; +"permissions_step4" = "Vuelva a MacOSCleaner y haga clic en 'Verificar estado'."; +"permissions_open_settings" = "Abrir Ajustes del sistema"; +"permissions_check_status" = "Verificar estado"; +"permissions_dismiss_temp" = "Saltar por ahora"; +"permissions_dismiss_permanent" = "No mostrar de nuevo"; +"permissions_status_granted" = "Concedido"; +"permissions_status_required" = "Requerido"; +"permissions_window_title" = "Permisos"; + +// Settings Permissions +"settings_permissions" = "Permisos"; +"settings_fda_description" = "Necesario para limpiar cachés del sistema y datos de aplicaciones."; +"settings_open_settings" = "Abrir ajustes"; +"settings_check_permissions" = "Verificar permisos"; + +// Format strings +"dashboard_used_percent_format" = "%lld%%"; +"dashboard_freed_prefix" = "+%@"; +"cleanup_mb_format" = "%lld MB"; + +// Process format strings +"processes_view_mode_grouped" = "Agrupado"; +"processes_view_mode_flat" = "Plano"; +"processes_selected_count" = "%lld seleccionado(s)"; +"processes_process_count" = "%lld procesos"; +"process_pid_format" = "PID %lld"; +"process_cpu_format" = "%.1f%%"; +"process_uptime_hours_format" = "%lldh %lldm"; +"process_uptime_minutes_format" = "%lldm"; + +// Common +"version_unknown" = "N/A"; + +// Operation Risk +"risk.safe" = "Seguro"; +"risk.moderate" = "Moderado"; +"risk.dangerous" = "Peligroso"; +"risk.protected" = "Protegido"; + +// Cleanup Dev Badge +"cleanup_dev_badge" = "DEV"; + +// Refresh Interval (Settings -> Processes) +"refresh_manual" = "Manual"; +"refresh_5s" = "Cada 5 segundos"; +"refresh_10s" = "Cada 10 segundos"; +"refresh_30s" = "Cada 30 segundos"; + +// Sort By (Settings -> Processes) +"sort_cpu" = "Uso de CPU"; +"sort_memory" = "Uso de memoria"; +"sort_name" = "Nombre"; +"sort_threads" = "Número de hilos"; + +// Cleanup Category Names +"category.app_caches" = "Cachés de aplicaciones de usuario"; +"category.package_managers" = "Gestores de paquetes"; +"category.gradle_maven" = "Gradle + Maven"; +"category.flutter_dart" = "Flutter / Dart"; +"category.xcode" = "Xcode"; +"category.ios_simulators" = "Simuladores de iOS"; +"category.android_caches" = "Cachés de Android"; +"category.android_sdk" = "Android SDK"; +"category.ide_caches" = "Cachés de IDE / Electron"; +"category.browser_caches" = "Cachés de navegador"; +"category.messaging_media" = "Mensajería / multimedia"; +"category.docker" = "Docker"; +"category.language_caches" = "Cachés de lenguaje"; +"category.user_logs" = "Registros de usuario"; +"category.system_caches" = "Cachés del sistema"; +"category.app_containers" = "Contenedores de aplicaciones"; +"category.dotfile_caches" = "Cachés de archivos de punto"; +"category.scattered_junk" = "Basura dispersa"; +"category.orphaned_remnants" = "Restos huérfanos"; +"category.orphaned_files" = "Archivos huérfanos"; +"category.large_files" = "Archivos grandes"; +"category.dynamic_cache_discovery" = "Descubrimiento dinámico de caché"; +"category.time_machine_snapshots" = "Instantáneas de Time Machine"; +"category.ios_backups" = "Copias de seguridad de iOS"; +"category.mail_downloads" = "Descargas de Mail"; +"category.saved_app_state" = "Estado de aplicación guardado"; +"category.crash_reporter" = "Informes de fallos"; +"category.assets_v2" = "AssetsV2 / Plantillas de iWork"; +"category.cloud_kit_cache" = "Caché de iCloud CloudKit"; +"category.swift_pm_cache" = "Caché de Swift Package Manager"; +"category.carthage_cache" = "Caché de Carthage"; +"category.steam_cache" = "Caché de Steam"; +"category.teams_cache" = "Caché de Microsoft Teams"; +"category.adobe_caches" = "Cachés de Adobe"; +"category.chrome_extra_caches" = "Cachés adicionales de Chrome"; +"category.ide_old_versions" = "Versiones antiguas de IDE"; +"category.launch_agents" = "Agentes de lanzamiento"; +"category.launch_daemons" = "Demonios de lanzamiento"; +"category.privileged_helpers" = "Herramientas auxiliares privilegiadas"; +"category.pkg_receipts" = "Recibos de paquetes"; +"category.internet_plugins" = "Plugins de Internet"; +"category.shared_file_lists" = "Listas de archivos compartidos"; +"category.cloud_docs" = "Documentos en la nube"; +"category.photos_cache" = "Caché de fotos"; +"category.voice_memos" = "Notas de voz"; +"category.garage_band_logic" = "GarageBand / Logic Pro"; +"category.imovie_final_cut" = "iMovie / Final Cut"; +"category.garmin_fitbit" = "Garmin / Fitbit"; +"category.old_backups" = "Copias de seguridad antiguas"; +"category.dns_flush" = "Caché de DNS"; +"category.font_cache" = "Caché de fuentes"; +"category.sleep_image" = "Imagen de suspensión"; +"category.duplicate_files" = "Archivos duplicados"; +"category.unused_apps" = "Aplicaciones no utilizadas"; + +// Process View - Missing Keys +"view_mode" = "Modo de vista"; +"sort_by" = "Ordenar por"; +"cancel_selection" = "Cancelar selección"; +"select_multiple" = "Seleccionar múltiples"; +"select_all" = "Seleccionar todo"; +"deselect_all" = "Deseleccionar todo"; +"terminate_selected" = "Terminar seleccionados"; +"force_kill_selected" = "Forzar cierre de seleccionados"; +"processes_terminate_all" = "Terminar todos"; +"processes_force_kill_all" = "Forzar cierre de todos"; +"process.unknown" = "Desconocido"; + +// Uninstaller - Scan Progress +"uninstaller.progress.discovering" = "Descubriendo aplicaciones..."; +"uninstaller.progress.complete" = "Escaneo completo"; + +// Uninstaller - Confidence Tiers +"uninstaller.tier.ignore" = "Ignorar"; +"uninstaller.tier.possible" = "Posible"; +"uninstaller.tier.very_likely" = "Muy probable"; +"uninstaller.tier.guaranteed" = "Garantizado"; + +// Developer Components +"developer.android_sdk" = "Android SDK"; +"developer.gradle_cache" = "Caché de Gradle"; +"developer.xcode_derived_data" = "Xcode Derived Data"; +"developer.ios_simulators" = "Simuladores de iOS"; +"developer.flutter_cache" = "Caché de Flutter"; +"developer.docker" = "Docker"; +"developer.homebrew" = "Homebrew"; + +// Evidence Categories +"uninstaller.evidence_category.identity" = "Coincidencia de identidad"; +"uninstaller.evidence_category.signature" = "Firma de código"; +"uninstaller.evidence_category.system" = "Integración del sistema"; +"uninstaller.evidence_category.metadata" = "Metadatos del archivo"; +"uninstaller.evidence_category.content" = "Análisis de contenido"; +"uninstaller.evidence_category.graph" = "Propagación de grafo"; +"uninstaller.evidence_category.launch_services" = "Servicios de lanzamiento"; + +// Evidence Descriptions +"uninstaller.evidence.bundleIDExact.title" = "Coincidencia de Bundle ID"; +"uninstaller.evidence.bundleIDExact.description" = "El nombre coincide con el identificador de paquete de la aplicación."; +"uninstaller.evidence.bundleIDPrefix.title" = "Prefijo de Bundle ID"; +"uninstaller.evidence.bundleIDPrefix.description" = "El nombre comienza con '%@'."; +"uninstaller.evidence.appNameExact.title" = "Coincidencia de nombre de app"; +"uninstaller.evidence.appNameExact.description" = "El nombre coincide con el nombre de la aplicación."; +"uninstaller.evidence.appNamePrefix.title" = "Prefijo de nombre de app"; +"uninstaller.evidence.appNamePrefix.description" = "El nombre comienza con el nombre de la aplicación."; +"uninstaller.evidence.executableName.title" = "Nombre del ejecutable"; +"uninstaller.evidence.executableName.description" = "El nombre coincide con el ejecutable de la aplicación."; +"uninstaller.evidence.frameworkName.title" = "Nombre del framework"; +"uninstaller.evidence.frameworkName.description" = "El archivo es un framework utilizado por la aplicación."; +"uninstaller.evidence.xpcServiceName.title" = "Servicio XPC"; +"uninstaller.evidence.xpcServiceName.description" = "El archivo es un servicio XPC utilizado por la aplicación."; +"uninstaller.evidence.plugInName.title" = "Nombre del plug-in"; +"uninstaller.evidence.plugInName.description" = "El archivo es un plug-in utilizado por la aplicación."; +"uninstaller.evidence.vendorName.title" = "Nombre del proveedor"; +"uninstaller.evidence.vendorName.description" = "El archivo pertenece al mismo proveedor."; +"uninstaller.evidence.teamID.title" = "Coincidencia de Team ID"; +"uninstaller.evidence.teamID.description" = "Firmado por el equipo %@, coincidiendo con la aplicación."; +"uninstaller.evidence.developerSignature.title" = "Firma del desarrollador"; +"uninstaller.evidence.developerSignature.description" = "Firmado por el mismo certificado de desarrollador."; +"uninstaller.evidence.launchAgent.title" = "Agente de lanzamiento"; +"uninstaller.evidence.launchAgent.description" = "Un agente de lanzamiento registrado por la aplicación."; +"uninstaller.evidence.launchDaemon.title" = "Demonio de lanzamiento"; +"uninstaller.evidence.launchDaemon.description" = "Un demonio de lanzamiento registrado por la aplicación."; +"uninstaller.evidence.loginItem.title" = "Elemento de inicio de sesión"; +"uninstaller.evidence.loginItem.description" = "Un elemento de inicio de sesión registrado por la aplicación."; +"uninstaller.evidence.appGroup.title" = "Grupo de aplicaciones"; +"uninstaller.evidence.appGroup.description" = "Pertenece al contenedor de grupo de la aplicación."; +"uninstaller.evidence.container.title" = "Contenedor de la aplicación"; +"uninstaller.evidence.container.description" = "Contenedor de sandbox de la aplicación."; +"uninstaller.evidence.extension.title" = "Extensión de la aplicación"; +"uninstaller.evidence.extension.description" = "Extensión de aplicación registrada por la app."; +"uninstaller.evidence.xpcConnection.title" = "Conexión XPC"; +"uninstaller.evidence.xpcConnection.description" = "Una conexión XPC utilizada por la aplicación."; +"uninstaller.evidence.packageReceipt.title" = "Recibo de paquete"; +"uninstaller.evidence.packageReceipt.description" = "Registrado mediante un recibo de paquete."; +"uninstaller.evidence.plistContent.title" = "Contenido de Plist"; +"uninstaller.evidence.plistContent.description" = "La lista de propiedades contiene el nombre o Bundle ID de la aplicación."; +"uninstaller.evidence.spotlight.title" = "Índice de Spotlight"; +"uninstaller.evidence.spotlight.description" = "Encontrado mediante búsqueda de Spotlight."; +"uninstaller.evidence.spotlightBundleAttr.title" = "Atributo de paquete de Spotlight"; +"uninstaller.evidence.spotlightBundleAttr.description" = "Los metadatos de Spotlight informan del identificador de paquete '%@'."; +"uninstaller.evidence.spotlightCreator.title" = "Creador de Spotlight"; +"uninstaller.evidence.spotlightCreator.description" = "Los metadatos del creador de Spotlight coinciden."; +"uninstaller.evidence.fileContent.title" = "Contenido del archivo"; +"uninstaller.evidence.fileContent.description" = "El contenido del archivo hace referencia a la aplicación."; +"uninstaller.evidence.electronCache.title" = "Caché de Electron"; +"uninstaller.evidence.electronCache.description" = "Caché de aplicación basada en Electron."; +"uninstaller.evidence.jetBrainsConfig.title" = "Configuración de JetBrains"; +"uninstaller.evidence.jetBrainsConfig.description" = "Configuración de IDE JetBrains."; +"uninstaller.evidence.flutterBuild.title" = "Compilación de Flutter"; +"uninstaller.evidence.flutterBuild.description" = "Artefacto de compilación de Flutter."; +"uninstaller.evidence.parentDirectory.title" = "Directorio padre"; +"uninstaller.evidence.parentDirectory.description" = "Encontrado en un directorio relacionado con la aplicación."; +"uninstaller.evidence.launchServicesRegistered.title" = "Servicios de lanzamiento"; +"uninstaller.evidence.launchServicesRegistered.description" = "Registrado en la base de datos de Servicios de lanzamiento."; + +// Permissions - Missing Access Descriptions +"permissions.full_disk_access" = "Acceso completo al disco"; +"permissions.accessibility" = "Accesibilidad"; +"permissions.automation" = "Automatización (Apple Events)"; +"permissions.trash_access" = "Acceso a la papelera"; +"permissions.notification_provisional" = "Provisional"; +"permissions.notification_ephemeral" = "Efímero"; +"permissions.unknown_status" = "Desconocido"; + +// Process Categories +"process.category.applications" = "Aplicaciones"; +"process.category.launch_agents" = "Agentes de lanzamiento"; +"process.category.launch_daemons" = "Demonios de lanzamiento"; +"process.category.system" = "Sistema"; + +// Uninstaller - Additional UI +"uninstaller.scanning_deep" = "Escaneo profundo..."; +"uninstaller.why_this_file" = "¿Por qué este archivo?"; +"uninstaller.related_files" = "Archivos relacionados"; +"uninstaller.developer_artifacts" = "Artefactos de desarrollador"; +"uninstaller.progress.developer_components" = "Verificando componentes de desarrollador..."; +"uninstaller.footer.summary" = "%lld archivo(s) seleccionado(s) en %@ niveles"; + +// Format Helpers +"format_bytes_b" = "%lld B"; +"format_bytes_kb" = "%.1f KB"; +"format_bytes_mb" = "%.1f MB"; +"format_bytes_gb" = "%.2f GB"; + +// Process Block Reasons +"process_block_pid_format" = "El PID %lld es un proceso crítico del sistema"; +"process_block_whitelist_name_format" = "%@ está en su lista blanca (protegido)"; +"process_block_whitelist_bundle_format" = "%@ está en su lista blanca (protegido)"; +"process_block_protected_format" = "%@ es un proceso protegido del sistema"; +"process_block_no_path_format" = "%@ no tiene información de ruta — proceda con precaución"; + +// Error Messages +"error_ps_failed_format" = "Error al listar procesos: %@"; +"error_operation_blocked_format" = "No se puede terminar %@: %@"; +"error_kill_failed_format" = "Error al terminar %@ (código %lld): %@"; +"error_timeout" = "Tiempo de operación agotado"; +"error_safety_violation_format" = "Violación de seguridad: %@"; +"error_command_failed_format" = "Comando fallido: %@"; +"error_invalid_transition_format" = "Transición inválida de %@ a %@"; + +// OS Version +"os_version_format" = "macOS %lld.%lld.%lld"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 6f15937..b125f96 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -3,6 +3,7 @@ "app_title" = "Очистка"; "sidebar_select_item" = "Выберите элемент в боковом меню"; "close" = "Закрыть"; +"cancel_description" = "Операция была отменена пользователем."; "reset" = "Сбросить"; "cancel" = "Отмена"; "done" = "Готово"; @@ -47,6 +48,12 @@ "dashboard_recent_operations" = "Недавние операции"; "dashboard_no_recent_operations" = "Нет недавних операций"; +// Language Names +"language.english" = "Английский"; +"language.russian" = "Русский"; +"language.ukrainian" = "Украинский"; +"language.spanish" = "Испанский"; + // Settings View "settings_title" = "Настройки"; "settings_subtitle" = "Настройка параметров приложения"; @@ -194,6 +201,8 @@ // Trash "trash_user_label" = "Пользовательская корзина"; "trash_user_description" = "Содержимое вашей корзины. Включает удаленные файлы."; +"trash_access_prompt_message" = "Пожалуйста, выберите папку Корзины для предоставления доступа к сканированию и очистке. (Она уже выбрана, просто нажмите «Предоставить доступ»)."; +"trash_access_prompt_button" = "Предоставить доступ"; // Uninstaller View "uninstaller_title" = "Деинсталлятор"; @@ -225,6 +234,8 @@ "uninstaller_expert_tip" = "В Экспертном режиме вы можете выборочно удалять остаточные файлы, такие как кэши и настройки."; "uninstaller_cleanup_items" = "Элементы для очистки"; "uninstaller_scanning_apps" = "Сканирование приложений..."; +"uninstaller.deep_scanning_progress" = "Сбор остатков: %d из %d приложений..."; +"uninstaller.analyzing" = "Анализ..."; "uninstaller_complete_title" = "Удаление завершено"; "uninstaller_complete_body" = "Приложение %@ было успешно удалено."; "shared_data_warning" = "Эти данные общие для других приложений (например, Android SDK, AVD). Удаление может повлиять на другие IDE."; @@ -285,6 +296,30 @@ "settings_open_settings" = "Открыть настройки"; "settings_check_permissions" = "Проверить разрешения"; +// Format strings +"dashboard_used_percent_format" = "%lld%%"; +"dashboard_freed_prefix" = "+%@"; +"cleanup_mb_format" = "%lld МБ"; + +// Process format strings +"processes_view_mode_grouped" = "Группировать"; +"processes_view_mode_flat" = "Списком"; +"processes_selected_count" = "Выбрано: %lld"; +"processes_process_count" = "%lld процессов"; +"process_pid_format" = "PID %lld"; +"process_cpu_format" = "%.1f%%"; +"process_uptime_hours_format" = "%lldч %lldм"; +"process_uptime_minutes_format" = "%lldм"; + +// Common +"version_unknown" = "N/A"; + +// Operation Risk +"risk.safe" = "Безопасно"; +"risk.moderate" = "Умеренно"; +"risk.dangerous" = "Опасно"; +"risk.protected" = "Защищено"; + // Cleanup Dev Badge "cleanup_dev_badge" = "РАЗРАБ"; @@ -300,17 +335,207 @@ "sort_name" = "Имя"; "sort_threads" = "Кол-во потоков"; -// New cleanup categories -"time_machine_snapshots" = "Снимки Time Machine"; -"ios_backups" = "Резервные копии iOS"; -"mail_downloads" = "Загрузки Mail"; -"saved_app_state" = "Сохранённое состояние приложений"; -"crash_reporter" = "Отчёты о сбоях"; -"assets_v2" = "AssetsV2 / Шаблоны iWork"; -"cloud_kit_cache" = "Кэш iCloud CloudKit"; -"swift_pm_cache" = "Кэш Swift Package Manager"; -"carthage_cache" = "Кэш Carthage"; -"steam_cache" = "Кэш Steam"; -"teams_cache" = "Кэш Microsoft Teams"; -"adobe_caches" = "Кэши Adobe"; -"chrome_extra_caches" = "Доп. кэши Chrome"; +// Cleanup Category Names +"category.app_caches" = "Кэши приложений"; +"category.package_managers" = "Менеджеры пакетов"; +"category.gradle_maven" = "Gradle + Maven"; +"category.flutter_dart" = "Flutter / Dart"; +"category.xcode" = "Xcode"; +"category.ios_simulators" = "Симуляторы iOS"; +"category.android_caches" = "Кэши Android"; +"category.android_sdk" = "Android SDK"; +"category.ide_caches" = "Кэши IDE / Electron"; +"category.browser_caches" = "Кэши браузеров"; +"category.messaging_media" = "Мессенджеры / медиа"; +"category.docker" = "Docker"; +"category.language_caches" = "Кэши языков"; +"category.user_logs" = "Логи пользователя"; +"category.system_caches" = "Системные кэши"; +"category.app_containers" = "Контейнеры приложений"; +"category.dotfile_caches" = "Кэши точечных файлов"; +"category.scattered_junk" = "Разрозненный мусор"; +"category.orphaned_remnants" = "Остаточные файлы"; +"category.orphaned_files" = "Файлы-сироты"; +"category.large_files" = "Большие файлы"; +"category.dynamic_cache_discovery" = "Динамическое обнаружение кэша"; +"category.time_machine_snapshots" = "Снимки Time Machine"; +"category.ios_backups" = "Резервные копии iOS"; +"category.mail_downloads" = "Загрузки Mail"; +"category.saved_app_state" = "Сохранённое состояние приложений"; +"category.crash_reporter" = "Отчёты о сбоях"; +"category.assets_v2" = "AssetsV2 / Шаблоны iWork"; +"category.cloud_kit_cache" = "Кэш iCloud CloudKit"; +"category.swift_pm_cache" = "Кэш Swift Package Manager"; +"category.carthage_cache" = "Кэш Carthage"; +"category.steam_cache" = "Кэш Steam"; +"category.teams_cache" = "Кэш Microsoft Teams"; +"category.adobe_caches" = "Кэши Adobe"; +"category.chrome_extra_caches" = "Доп. кэши Chrome"; +"category.ide_old_versions" = "Старые версии IDE"; +"category.launch_agents" = "Агенты автозапуска"; +"category.launch_daemons" = "Демоны"; +"category.privileged_helpers" = "Привилегированные инструменты"; +"category.pkg_receipts" = "Квитанции пакетов"; +"category.internet_plugins" = "Плагины интернета"; +"category.shared_file_lists" = "Общие списки файлов"; +"category.cloud_docs" = "Облачные документы"; +"category.photos_cache" = "Кэш фото"; +"category.voice_memos" = "Голосовые заметки"; +"category.garage_band_logic" = "GarageBand / Logic Pro"; +"category.imovie_final_cut" = "iMovie / Final Cut"; +"category.garmin_fitbit" = "Garmin / Fitbit"; +"category.old_backups" = "Старые резервные копии"; +"category.dns_flush" = "Кэш DNS"; +"category.font_cache" = "Кэш шрифтов"; +"category.sleep_image" = "Образ сна"; +"category.duplicate_files" = "Дубликаты файлов"; +"category.unused_apps" = "Неиспользуемые приложения"; + +// Process View - Missing Keys +"view_mode" = "Режим просмотра"; +"sort_by" = "Сортировка"; +"cancel_selection" = "Отменить выбор"; +"select_multiple" = "Выбрать несколько"; +"select_all" = "Выбрать все"; +"deselect_all" = "Снять выделение"; +"terminate_selected" = "Завершить выбранные"; +"force_kill_selected" = "Принудительно завершить выбранные"; +"processes_terminate_all" = "Завершить все"; +"processes_force_kill_all" = "Принудительно завершить все"; +"process.unknown" = "Неизвестно"; + +// Uninstaller - Scan Progress +"uninstaller.progress.discovering" = "Поиск приложений..."; +"uninstaller.progress.complete" = "Сканирование завершено"; + +// Uninstaller - Confidence Tiers +"uninstaller.tier.ignore" = "Игнорировать"; +"uninstaller.tier.possible" = "Возможно"; +"uninstaller.tier.very_likely" = "Очень вероятно"; +"uninstaller.tier.guaranteed" = "Гарантировано"; + +// Developer Components +"developer.android_sdk" = "Android SDK"; +"developer.gradle_cache" = "Кэш Gradle"; +"developer.xcode_derived_data" = "Xcode Derived Data"; +"developer.ios_simulators" = "Симуляторы iOS"; +"developer.flutter_cache" = "Кэш Flutter"; +"developer.docker" = "Docker"; +"developer.homebrew" = "Homebrew"; + +// Evidence Categories +"uninstaller.evidence_category.identity" = "Совпадение идентификатора"; +"uninstaller.evidence_category.signature" = "Подпись кода"; +"uninstaller.evidence_category.system" = "Системная интеграция"; +"uninstaller.evidence_category.metadata" = "Метаданные файла"; +"uninstaller.evidence_category.content" = "Анализ содержимого"; +"uninstaller.evidence_category.graph" = "Распространение графа"; +"uninstaller.evidence_category.launch_services" = "Launch Services"; + +// Evidence Descriptions +"uninstaller.evidence.bundleIDExact.title" = "Совпадение Bundle ID"; +"uninstaller.evidence.bundleIDExact.description" = "Имя совпадает с идентификатором пакета приложения."; +"uninstaller.evidence.bundleIDPrefix.title" = "Префикс Bundle ID"; +"uninstaller.evidence.bundleIDPrefix.description" = "Имя начинается с идентификатора пакета приложения."; +"uninstaller.evidence.appNameExact.title" = "Совпадение имени приложения"; +"uninstaller.evidence.appNameExact.description" = "Имя совпадает с названием приложения."; +"uninstaller.evidence.appNamePrefix.title" = "Префикс имени приложения"; +"uninstaller.evidence.appNamePrefix.description" = "Имя начинается с названия приложения."; +"uninstaller.evidence.executableName.title" = "Имя исполняемого файла"; +"uninstaller.evidence.executableName.description" = "Имя совпадает с исполняемым файлом приложения."; +"uninstaller.evidence.frameworkName.title" = "Имя фреймворка"; +"uninstaller.evidence.frameworkName.description" = "Файл является фреймворком, используемым приложением."; +"uninstaller.evidence.xpcServiceName.title" = "XPC-сервис"; +"uninstaller.evidence.xpcServiceName.description" = "Файл является XPC-сервисом приложения."; +"uninstaller.evidence.plugInName.title" = "Имя плагина"; +"uninstaller.evidence.plugInName.description" = "Файл является плагином приложения."; +"uninstaller.evidence.vendorName.title" = "Имя вендора"; +"uninstaller.evidence.vendorName.description" = "Файл принадлежит тому же разработчику."; +"uninstaller.evidence.teamID.title" = "Совпадение Team ID"; +"uninstaller.evidence.teamID.description" = "Подписано той же командой Apple Developer."; +"uninstaller.evidence.developerSignature.title" = "Подпись разработчика"; +"uninstaller.evidence.developerSignature.description" = "Подписано тем же сертификатом разработчика."; +"uninstaller.evidence.launchAgent.title" = "Launch Agent"; +"uninstaller.evidence.launchAgent.description" = "Автозагрузчик, зарегистрированный приложением."; +"uninstaller.evidence.launchDaemon.title" = "Launch Daemon"; +"uninstaller.evidence.launchDaemon.description" = "Демон, зарегистрированный приложением."; +"uninstaller.evidence.loginItem.title" = "Элемент входа"; +"uninstaller.evidence.loginItem.description" = "Элемент автозапуска, зарегистрированный приложением."; +"uninstaller.evidence.appGroup.title" = "Группа приложений"; +"uninstaller.evidence.appGroup.description" = "Принадлежит групповому контейнеру приложения."; +"uninstaller.evidence.container.title" = "Контейнер приложения"; +"uninstaller.evidence.container.description" = "Песочница приложения."; +"uninstaller.evidence.extension.title" = "Расширение приложения"; +"uninstaller.evidence.extension.description" = "Расширение, зарегистрированное приложением."; +"uninstaller.evidence.xpcConnection.title" = "XPC-соединение"; +"uninstaller.evidence.xpcConnection.description" = "XPC-соединение, используемое приложением."; +"uninstaller.evidence.packageReceipt.title" = "Квитанция пакета"; +"uninstaller.evidence.packageReceipt.description" = "Зарегистрировано через квитанцию пакета."; +"uninstaller.evidence.plistContent.title" = "Содержимое Plist"; +"uninstaller.evidence.plistContent.description" = "Plist содержит имя или Bundle ID приложения."; +"uninstaller.evidence.spotlight.title" = "Индекс Spotlight"; +"uninstaller.evidence.spotlight.description" = "Найдено через поиск Spotlight."; +"uninstaller.evidence.spotlightBundleAttr.title" = "Атрибут пакета Spotlight"; +"uninstaller.evidence.spotlightBundleAttr.description" = "Метаданные Spotlight ссылаются на пакет приложения."; +"uninstaller.evidence.spotlightCreator.title" = "Создатель Spotlight"; +"uninstaller.evidence.spotlightCreator.description" = "Метаданные создателя Spotlight совпадают."; +"uninstaller.evidence.fileContent.title" = "Содержимое файла"; +"uninstaller.evidence.fileContent.description" = "Содержимое файла ссылается на приложение."; +"uninstaller.evidence.electronCache.title" = "Кэш Electron"; +"uninstaller.evidence.electronCache.description" = "Кэш приложения на Electron."; +"uninstaller.evidence.jetBrainsConfig.title" = "Конфиг JetBrains"; +"uninstaller.evidence.jetBrainsConfig.description" = "Конфигурация IDE JetBrains."; +"uninstaller.evidence.flutterBuild.title" = "Сборка Flutter"; +"uninstaller.evidence.flutterBuild.description" = "Артефакт сборки Flutter."; +"uninstaller.evidence.parentDirectory.title" = "Родительский каталог"; +"uninstaller.evidence.parentDirectory.description" = "Найдено в каталоге, связанном с приложением."; +"uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; +"uninstaller.evidence.launchServicesRegistered.description" = "Зарегистрировано в базе Launch Services."; + +// Permissions - Missing Access Descriptions +"permissions.full_disk_access" = "Полный доступ к диску"; +"permissions.accessibility" = "Специальные возможности"; +"permissions.automation" = "Автоматизация (Apple Events)"; +"permissions.trash_access" = "Доступ к Корзине"; +"permissions.notification_provisional" = "Предварительные"; +"permissions.notification_ephemeral" = "Эфемерные"; +"permissions.unknown_status" = "Неизвестно"; + +// Process Categories +"process.category.applications" = "Приложения"; +"process.category.launch_agents" = "Агенты автозапуска"; +"process.category.launch_daemons" = "Демоны"; +"process.category.system" = "Системные"; + +// Uninstaller - Additional UI +"uninstaller.scanning_deep" = "Глубокое сканирование..."; +"uninstaller.why_this_file" = "Почему этот файл?"; +"uninstaller.related_files" = "Связанные файлы"; +"uninstaller.developer_artifacts" = "Артефакты разработчика"; +"uninstaller.progress.developer_components" = "Проверка компонентов разработчика..."; +"uninstaller.footer.summary" = "Выбрано %lld файлов в %@ категориях"; + +// Format Helpers +"format_bytes_b" = "%lld Б"; +"format_bytes_kb" = "%.1f КБ"; +"format_bytes_mb" = "%.1f МБ"; +"format_bytes_gb" = "%.2f ГБ"; + +// Process Block Reasons +"process_block_pid_format" = "PID %lld — системный критический процесс"; +"process_block_whitelist_name_format" = "%@ в вашем белом списке (защищён)"; +"process_block_whitelist_bundle_format" = "%@ в вашем белом списке (защищён)"; +"process_block_protected_format" = "%@ — защищённый системный процесс"; +"process_block_no_path_format" = "%@ — нет информации о пути, действуйте осторожно"; + +// Error Messages +"error_ps_failed_format" = "Не удалось получить список процессов: %@"; +"error_operation_blocked_format" = "Невозможно завершить %@: %@"; +"error_kill_failed_format" = "Не удалось завершить %@ (код %lld): %@"; +"error_timeout" = "Время операции истекло"; +"error_safety_violation_format" = "Нарушение безопасности: %@"; +"error_command_failed_format" = "Команда не выполнена: %@"; +"error_invalid_transition_format" = "Недопустимый переход из %@ в %@"; + +// OS Version +"os_version_format" = "macOS %lld.%lld.%lld"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index b61b400..440abd5 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -48,6 +48,12 @@ "dashboard_recent_operations" = "Недавні операції"; "dashboard_no_recent_operations" = "Немає недавніх операцій"; +// Language Names +"language.english" = "Англійська"; +"language.russian" = "Російська"; +"language.ukrainian" = "Українська"; +"language.spanish" = "Іспанська"; + // Settings View "settings_title" = "Налаштування"; "settings_subtitle" = "Налаштування параметрів додатка"; @@ -118,6 +124,30 @@ "startup_status_unloaded" = "Вивантажений"; "startup_disable" = "Вимкнути"; "startup_enable" = "Увімкнути"; +"startup_category_user" = "Користувацькі"; +"startup_category_third_party" = "Сторонні"; +"startup_category_system" = "Система"; + +// Startup Filters +"startup_filter_all" = "Усі"; + +// Startup Category Help +"startup_help_user" = "Служба користувача з ~/Library/. Безпечно вимикати."; +"startup_help_third_party" = "Стороння служба з /Library/. Вимикати з обережністю."; +"startup_help_system" = "Системна служба Apple. Не рекомендується вимикати."; + +// Startup Vendor Settings +"settings_startup_vendors" = "Системні вендори"; +"startup_vendors_title" = "Системні вендори"; +"startup_vendors_description" = "Префікси міток, які вважаються системними службами."; +"startup_vendors_description_sub" = "Служби з цими префіксами позначаються как 'Система' і не рекомендуються до вимкнення."; +"startup_vendors_current" = "Поточні префікси"; +"startup_vendors_reset" = "Скинути"; +"startup_vendors_empty" = "Префікси не додані"; +"startup_vendors_protected" = "Захищено"; +"startup_vendors_placeholder" = "com.vendor."; +"startup_vendors_error_no_dot" = "Префікс повинен містити крапку"; +"startup_vendors_error_duplicate" = "Цей префікс вже існує"; // Cleanup View "cleanup_scanning" = "Сканування системи..."; @@ -169,6 +199,8 @@ // Trash "trash_user_label" = "Користувацький смітник"; "trash_user_description" = "Вміст вашого смітника. Включає видалені файли."; +"trash_access_prompt_message" = "Будь ласка, виберіть папку Смітника для надання доступу до сканування та очищення. (Вона вже вибрана, просто натисніть «Надати доступ»)."; +"trash_access_prompt_button" = "Надати доступ"; // Uninstaller View "uninstaller_title" = "Деінсталятор"; @@ -200,6 +232,8 @@ "uninstaller_expert_tip" = "В Експертному режимі ви можете вибірково видаляти залишкові файли, такі як кеші та налаштування."; "uninstaller_cleanup_items" = "Елементи для очищення"; "uninstaller_scanning_apps" = "Сканування додатків..."; +"uninstaller.deep_scanning_progress" = "Збір залишків: %d із %d додатків..."; +"uninstaller.analyzing" = "Аналіз..."; "uninstaller_complete_title" = "Видалення завершено"; "uninstaller_complete_body" = "Додаток %@ був успішно видалений."; "shared_data_warning" = "Ці дані спільні для інших додатків (наприклад, Android SDK, AVD). Видалення може вплинути на інші IDE."; @@ -260,6 +294,30 @@ "settings_open_settings" = "Відкрити налаштування"; "settings_check_permissions" = "Перевірити дозволи"; +// Format strings +"dashboard_used_percent_format" = "%lld%%"; +"dashboard_freed_prefix" = "+%@"; +"cleanup_mb_format" = "%lld МБ"; + +// Process format strings +"processes_view_mode_grouped" = "Групувати"; +"processes_view_mode_flat" = "Списком"; +"processes_selected_count" = "Вибрано: %lld"; +"processes_process_count" = "%lld процесів"; +"process_pid_format" = "PID %lld"; +"process_cpu_format" = "%.1f%%"; +"process_uptime_hours_format" = "%lldгод %lldхв"; +"process_uptime_minutes_format" = "%lldхв"; + +// Common +"version_unknown" = "N/A"; + +// Operation Risk +"risk.safe" = "Безпечно"; +"risk.moderate" = "Помірно"; +"risk.dangerous" = "Небезпечно"; +"risk.protected" = "Захищено"; + // Cleanup Dev Badge "cleanup_dev_badge" = "РОЗРОБ"; @@ -275,17 +333,207 @@ "sort_name" = "Ім'я"; "sort_threads" = "К-сть потоків"; -// New cleanup categories -"time_machine_snapshots" = "Знімки Time Machine"; -"ios_backups" = "Резервні копії iOS"; -"mail_downloads" = "Завантаження Mail"; -"saved_app_state" = "Збережений стан додатків"; -"crash_reporter" = "Звіти про збої"; -"assets_v2" = "AssetsV2 / Шаблони iWork"; -"cloud_kit_cache" = "Кеш iCloud CloudKit"; -"swift_pm_cache" = "Кеш Swift Package Manager"; -"carthage_cache" = "Кеш Carthage"; -"steam_cache" = "Кеш Steam"; -"teams_cache" = "Кеш Microsoft Teams"; -"adobe_caches" = "Кеші Adobe"; -"chrome_extra_caches" = "Дод. кеші Chrome"; +// Cleanup Category Names +"category.app_caches" = "Кеші додатків"; +"category.package_managers" = "Менеджери пакетів"; +"category.gradle_maven" = "Gradle + Maven"; +"category.flutter_dart" = "Flutter / Dart"; +"category.xcode" = "Xcode"; +"category.ios_simulators" = "Симулятори iOS"; +"category.android_caches" = "Кеші Android"; +"category.android_sdk" = "Android SDK"; +"category.ide_caches" = "Кеші IDE / Electron"; +"category.browser_caches" = "Кеші браузерів"; +"category.messaging_media" = "Месенджери / медіа"; +"category.docker" = "Docker"; +"category.language_caches" = "Кеші мов"; +"category.user_logs" = "Логи користувача"; +"category.system_caches" = "Системні кеші"; +"category.app_containers" = "Контейнери додатків"; +"category.dotfile_caches" = "Кеші точкових файлів"; +"category.scattered_junk" = "Розрізнене сміття"; +"category.orphaned_remnants" = "Залишкові файли"; +"category.orphaned_files" = "Файли-сироти"; +"category.large_files" = "Великі файли"; +"category.dynamic_cache_discovery" = "Динамічне виявлення кешу"; +"category.time_machine_snapshots" = "Знімки Time Machine"; +"category.ios_backups" = "Резервні копії iOS"; +"category.mail_downloads" = "Завантаження Mail"; +"category.saved_app_state" = "Збережений стан додатків"; +"category.crash_reporter" = "Звіти про збої"; +"category.assets_v2" = "AssetsV2 / Шаблони iWork"; +"category.cloud_kit_cache" = "Кеш iCloud CloudKit"; +"category.swift_pm_cache" = "Кеш Swift Package Manager"; +"category.carthage_cache" = "Кеш Carthage"; +"category.steam_cache" = "Кеш Steam"; +"category.teams_cache" = "Кеш Microsoft Teams"; +"category.adobe_caches" = "Кеші Adobe"; +"category.chrome_extra_caches" = "Дод. кеші Chrome"; +"category.ide_old_versions" = "Старі версії IDE"; +"category.launch_agents" = "Агенти автозапуску"; +"category.launch_daemons" = "Демони"; +"category.privileged_helpers" = "Привілейовані інструменти"; +"category.pkg_receipts" = "Квитанції пакетів"; +"category.internet_plugins" = "Плагіни інтернету"; +"category.shared_file_lists" = "Спільні списки файлів"; +"category.cloud_docs" = "Хмарні документи"; +"category.photos_cache" = "Кеш фото"; +"category.voice_memos" = "Голосові нотатки"; +"category.garage_band_logic" = "GarageBand / Logic Pro"; +"category.imovie_final_cut" = "iMovie / Final Cut"; +"category.garmin_fitbit" = "Garmin / Fitbit"; +"category.old_backups" = "Старі резервні копії"; +"category.dns_flush" = "Кеш DNS"; +"category.font_cache" = "Кеш шрифтів"; +"category.sleep_image" = "Образ сну"; +"category.duplicate_files" = "Дублікати файлів"; +"category.unused_apps" = "Невикористовувані додатки"; + +// Process View - Missing Keys +"view_mode" = "Режим перегляду"; +"sort_by" = "Сортування"; +"cancel_selection" = "Скасувати вибір"; +"select_multiple" = "Вибрати декілька"; +"select_all" = "Вибрати все"; +"deselect_all" = "Зняти виділення"; +"terminate_selected" = "Завершити вибрані"; +"force_kill_selected" = "Примусово завершити вибрані"; +"processes_terminate_all" = "Завершити всі"; +"processes_force_kill_all" = "Примусово завершити всі"; +"process.unknown" = "Невідомо"; + +// Uninstaller - Scan Progress +"uninstaller.progress.discovering" = "Пошук додатків..."; +"uninstaller.progress.complete" = "Сканування завершено"; + +// Uninstaller - Confidence Tiers +"uninstaller.tier.ignore" = "Ігнорувати"; +"uninstaller.tier.possible" = "Можливо"; +"uninstaller.tier.very_likely" = "Дуже ймовірно"; +"uninstaller.tier.guaranteed" = "Гарантовано"; + +// Developer Components +"developer.android_sdk" = "Android SDK"; +"developer.gradle_cache" = "Кеш Gradle"; +"developer.xcode_derived_data" = "Xcode Derived Data"; +"developer.ios_simulators" = "Симулятори iOS"; +"developer.flutter_cache" = "Кеш Flutter"; +"developer.docker" = "Docker"; +"developer.homebrew" = "Homebrew"; + +// Evidence Categories +"uninstaller.evidence_category.identity" = "Збіг ідентифікатора"; +"uninstaller.evidence_category.signature" = "Підпис коду"; +"uninstaller.evidence_category.system" = "Системна інтеграція"; +"uninstaller.evidence_category.metadata" = "Метадані файлу"; +"uninstaller.evidence_category.content" = "Аналіз вмісту"; +"uninstaller.evidence_category.graph" = "Поширення графа"; +"uninstaller.evidence_category.launch_services" = "Launch Services"; + +// Evidence Descriptions +"uninstaller.evidence.bundleIDExact.title" = "Збіг Bundle ID"; +"uninstaller.evidence.bundleIDExact.description" = "Ім'я збігається з ідентифікатором пакета додатка."; +"uninstaller.evidence.bundleIDPrefix.title" = "Префікс Bundle ID"; +"uninstaller.evidence.bundleIDPrefix.description" = "Ім'я починається з ідентифікатора пакета додатка."; +"uninstaller.evidence.appNameExact.title" = "Збіг назви додатка"; +"uninstaller.evidence.appNameExact.description" = "Ім'я збігається з назвою додатка."; +"uninstaller.evidence.appNamePrefix.title" = "Префікс назви додатка"; +"uninstaller.evidence.appNamePrefix.description" = "Ім'я починається з назви додатка."; +"uninstaller.evidence.executableName.title" = "Ім'я виконуваного файлу"; +"uninstaller.evidence.executableName.description" = "Ім'я збігається з виконуваним файлом додатка."; +"uninstaller.evidence.frameworkName.title" = "Ім'я фреймворку"; +"uninstaller.evidence.frameworkName.description" = "Файл є фреймворком, який використовує додаток."; +"uninstaller.evidence.xpcServiceName.title" = "XPC-сервіс"; +"uninstaller.evidence.xpcServiceName.description" = "Файл є XPC-сервісом додатка."; +"uninstaller.evidence.plugInName.title" = "Ім'я плагіна"; +"uninstaller.evidence.plugInName.description" = "Файл є плагіном додатка."; +"uninstaller.evidence.vendorName.title" = "Ім'я вендора"; +"uninstaller.evidence.vendorName.description" = "Файл належить тому ж розробнику."; +"uninstaller.evidence.teamID.title" = "Збіг Team ID"; +"uninstaller.evidence.teamID.description" = "Підписано тією ж командою Apple Developer."; +"uninstaller.evidence.developerSignature.title" = "Підпис розробника"; +"uninstaller.evidence.developerSignature.description" = "Підписано тим же сертифікатом розробника."; +"uninstaller.evidence.launchAgent.title" = "Launch Agent"; +"uninstaller.evidence.launchAgent.description" = "Автозавантажувач, зареєстрований додатком."; +"uninstaller.evidence.launchDaemon.title" = "Launch Daemon"; +"uninstaller.evidence.launchDaemon.description" = "Демон, зареєстрований додатком."; +"uninstaller.evidence.loginItem.title" = "Елемент входу"; +"uninstaller.evidence.loginItem.description" = "Елемент автозапуску, зареєстрований додатком."; +"uninstaller.evidence.appGroup.title" = "Група додатків"; +"uninstaller.evidence.appGroup.description" = "Належить груповому контейнеру додатка."; +"uninstaller.evidence.container.title" = "Контейнер додатка"; +"uninstaller.evidence.container.description" = "Пісочниця додатка."; +"uninstaller.evidence.extension.title" = "Розширення додатка"; +"uninstaller.evidence.extension.description" = "Розширення, зареєстроване додатком."; +"uninstaller.evidence.xpcConnection.title" = "XPC-з'єднання"; +"uninstaller.evidence.xpcConnection.description" = "XPC-з'єднання, яке використовує додаток."; +"uninstaller.evidence.packageReceipt.title" = "Квитанція пакета"; +"uninstaller.evidence.packageReceipt.description" = "Зареєстровано через квитанцію пакета."; +"uninstaller.evidence.plistContent.title" = "Вміст Plist"; +"uninstaller.evidence.plistContent.description" = "Plist містить назву або Bundle ID додатка."; +"uninstaller.evidence.spotlight.title" = "Індекс Spotlight"; +"uninstaller.evidence.spotlight.description" = "Знайдено через пошук Spotlight."; +"uninstaller.evidence.spotlightBundleAttr.title" = "Атрибут пакета Spotlight"; +"uninstaller.evidence.spotlightBundleAttr.description" = "Метадані Spotlight посилаються на пакет додатка."; +"uninstaller.evidence.spotlightCreator.title" = "Творець Spotlight"; +"uninstaller.evidence.spotlightCreator.description" = "Метадані творця Spotlight збігаються."; +"uninstaller.evidence.fileContent.title" = "Вміст файлу"; +"uninstaller.evidence.fileContent.description" = "Вміст файлу посилається на додаток."; +"uninstaller.evidence.electronCache.title" = "Кеш Electron"; +"uninstaller.evidence.electronCache.description" = "Кеш додатка на Electron."; +"uninstaller.evidence.jetBrainsConfig.title" = "Конфіг JetBrains"; +"uninstaller.evidence.jetBrainsConfig.description" = "Конфігурація IDE JetBrains."; +"uninstaller.evidence.flutterBuild.title" = "Збірка Flutter"; +"uninstaller.evidence.flutterBuild.description" = "Артефакт збірки Flutter."; +"uninstaller.evidence.parentDirectory.title" = "Батьківський каталог"; +"uninstaller.evidence.parentDirectory.description" = "Знайдено в каталозі, пов'язаному з додатком."; +"uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; +"uninstaller.evidence.launchServicesRegistered.description" = "Зареєстровано в базі Launch Services."; + +// Permissions - Missing Access Descriptions +"permissions.full_disk_access" = "Повний доступ до диска"; +"permissions.accessibility" = "Спеціальні можливості"; +"permissions.automation" = "Автоматизація (Apple Events)"; +"permissions.trash_access" = "Доступ до Смітника"; +"permissions.notification_provisional" = "Попередні"; +"permissions.notification_ephemeral" = "Ефемерні"; +"permissions.unknown_status" = "Невідомо"; + +// Process Categories +"process.category.applications" = "Додатки"; +"process.category.launch_agents" = "Агенти автозапуску"; +"process.category.launch_daemons" = "Демони"; +"process.category.system" = "Системні"; + +// Uninstaller - Additional UI +"uninstaller.scanning_deep" = "Глибоке сканування..."; +"uninstaller.why_this_file" = "Чому цей файл?"; +"uninstaller.related_files" = "Пов'язані файли"; +"uninstaller.developer_artifacts" = "Артефакти розробника"; +"uninstaller.progress.developer_components" = "Перевірка компонентів розробника..."; +"uninstaller.footer.summary" = "Вибрано %lld файлів у %@ категоріях"; + +// Format Helpers +"format_bytes_b" = "%lld Б"; +"format_bytes_kb" = "%.1f КБ"; +"format_bytes_mb" = "%.1f МБ"; +"format_bytes_gb" = "%.2f ГБ"; + +// Process Block Reasons +"process_block_pid_format" = "PID %lld — системний критичний процес"; +"process_block_whitelist_name_format" = "%@ у вашому білому списку (захищено)"; +"process_block_whitelist_bundle_format" = "%@ у вашому білому списку (захищено)"; +"process_block_protected_format" = "%@ — захищений системний процес"; +"process_block_no_path_format" = "%@ — немає інформації про шлях, дійте обережно"; + +// Error Messages +"error_ps_failed_format" = "Не вдалося отримати список процесів: %@"; +"error_operation_blocked_format" = "Неможливо завершити %@: %@"; +"error_kill_failed_format" = "Не вдалося завершити %@ (код %lld): %@"; +"error_timeout" = "Час операції вичерпано"; +"error_safety_violation_format" = "Порушення безпеки: %@"; +"error_command_failed_format" = "Команда не виконана: %@"; +"error_invalid_transition_format" = "Неприпустимий перехід із %@ у %@"; + +// OS Version +"os_version_format" = "macOS %lld.%lld.%lld"; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index b4dcf47..9ac0b7b 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -20,23 +20,29 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleaner - MARKETING_VERSION: 1.0.1 + MARKETING_VERSION: 1.1 CURRENT_PROJECT_VERSION: 1 ENABLE_HARDENED_RUNTIME: YES SWIFT_VERSION: 6.0 CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.utilities" INFOPLIST_KEY_NSAppleEventsUsageDescription: "MacOSCleaner needs to send Apple Events to close applications before cleaning their caches." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 MacOSCleaner. All rights reserved." - SWIFT_OPTIMIZATION_LEVEL: "-O" SWIFT_COMPILATION_MODE: wholemodule DEAD_CODE_STRIPPING: YES SWIFT_STRICT_CONCURRENCY: minimal CLANG_ANALYZER_NONNULL: YES GCC_WARN_ABOUT_RETURN_TYPE: YES_ERROR COPY_PHASE_STRIP: NO + configs: + Debug: + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + SWIFT_COMPILATION_MODE: singlefile + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" MacOSCleanerTests: type: bundle.unit-test @@ -51,4 +57,5 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleanerTests SWIFT_VERSION: 6.0 CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" GENERATE_INFOPLIST_FILE: YES diff --git a/README.md b/README.md index 3542055..33ffa0b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Language: Swift 6](https://img.shields.io/badge/Language-Swift%206-FA7343.svg?logo=swift&logoColor=white)](https://swift.org) [![UI: SwiftUI](https://img.shields.io/badge/UI-SwiftUI-007AFF.svg?logo=swift&logoColor=white)](https://developer.apple.com/xcode/swiftui/) [![Build: XcodeGen](https://img.shields.io/badge/Build-XcodeGen-black.svg?logo=xcode&logoColor=white)](https://github.com/yonaskolb/XcodeGen) -[![Version: 1.0.1](https://img.shields.io/badge/Release-1.0.1-brightgreen.svg)]() +[![Version: 1.1](https://img.shields.io/badge/Release-1.1-brightgreen.svg)]() 🧹 Free up disk space by cleaning caches, temp files, app leftovers, and more. Everything goes to Trash — nothing is gone forever unless you say so. @@ -18,31 +18,34 @@ ## Screenshots

- - + +

- - + +

- 📷 View all screenshots + 📷 View all screenshots

--- ## Features -**Dashboard** 📊 — disk usage chart, system info (model, CPU, RAM, macOS version), cleanup history and stats. +🌍 **Fully Localized** — English, Русский, Українська, Español. All UI, errors, logs, and system info translated dynamically. Dates and byte counts format automatically for your language. -**Smart Cleanup** 🔍 — scans 35 categories at once: +**Dashboard** 📊 — redesigned with native macOS aesthetics: `controlBackgroundColor`, rounded cards, and SF Symbols. Disk usage chart, system info (model, CPU, RAM, macOS version), cleanup history and stats. + +**Smart Cleanup** 🔍 — scans 54 categories with 298 built-in cleaning paths: - **App Caches** — Google, Spotify, JetBrains, opencode, browsers (Safari, Chrome, Firefox, Edge, Brave, Vivaldi, Arc), messengers (Telegram, Discord, Slack, Signal, WeChat, Teams) - **Package Managers** — Homebrew, npm, yarn, pnpm, CocoaPods -- **Dev Tools** — Xcode DerivedData, iOS Simulators (old runtimes too), Android SDK + Studio caches, Gradle/Maven, Flutter/Dart, language caches (Go, Rust, Python, Node.js, Ruby, Java, Julia, Elixir, Haskell, Swift PM, R) +- **Dev Tools** — Xcode DerivedData, iOS Simulators (old runtimes too), Android SDK + Studio caches, Gradle/Maven, Flutter/Dart, language caches (Go, Rust, Python, Node.js, Ruby, Java, Julia, Elixir, Haskell, Swift PM, R, Maven, pnpm-store, Yarn, Poetry, Cargo git, SwiftPM repos, Bazel) - **IDE Caches** — Cursor, VS Code (incl. Insiders), Windsurf, Zed, JetBrains, Nova, Sublime Text, Atom, Eclipse, opencode, Claude, ChatGPT, Gemini, Perplexity, GitHub Desktop, Slack, Discord, Figma, Notion, Postman, Insomnia, Linear, Tower, TablePlus + dynamic Electron cache discovery -- **System Caches** — QuickLook, fonts, Spotlight, Siri, CloudKit, TimeMachine, icons +- **Browser Sub-Caches** — Firefox Profiles/*/cache2, Safari LocalStorage/Databases, Chrome Code Cache/GPUCache/Service Worker/GrShaderCache, Edge/Brave/Arc Code Cache +- **System Caches** — QuickLook ThumbnailsAgent, fonts, Spotlight, Siri, CloudKit, TimeMachine, icons - **Docker** — container and image cleanup - **App Containers** — sandboxed caches in Containers + Group Containers - **Dotfile Caches** — AI CLI tools (opencode, Claude, Gemini, Codex, Aider), dev tools (npm logs, Terraform, Helm, Bazel, ccache, vcpkg) @@ -53,7 +56,7 @@ - **Dynamic Cache Discovery** — auto-discovers large reverse-DNS caches in ~/Library/Caches; Apple caches (com.apple.*) at ≥ 5 MB, others at ≥ 20 MB - **Time Machine Snapshots** — local APFS snapshots (macOS recreates them automatically) - **iOS Backups** — re-downloadable from iCloud -- **Mail Downloads** — cached email attachments +- **Mail Downloads** — cached email attachments (all Mail accounts) - **Saved Application State** — window/session state (recreated on app launch) - **Crash Reports** — old crash logs and diagnostic files - **AssetsV2 / iWork Templates** — Pages/Numbers/Keynote templates (~800 MB, re-downloaded on demand) @@ -63,26 +66,66 @@ - **Steam Cache** — app cache, shader cache, depot cache, logs - **Teams Cache** — Electron caches (Cache, Code Cache, GPUCache, IndexedDB) - **Adobe Caches** — application and media caches -- **Chrome Extra Caches** — disk cache, code cache, GPU cache, service workers +- **Chrome Extra Caches** — GrShaderCache, disk cache, code cache, GPU cache, service workers +- **Launch Agents** — user-level LaunchAgents in ~/Library/LaunchAgents +- **Launch Daemons** — system-level LaunchDaemons (sudo) +- **Privileged Helpers** — system helper tools (sudo) +- **Package Receipts** — pkgutil receipt databases +- **Internet Plug-Ins** — legacy browser plug-ins +- **Shared File Lists** — Finder sidebar / recent items lists +- **iCloud Cloud Documents** — iCloud document cache (opt-in) +- **Photos Library Cache** — Photos.app library cache +- **Voice Memos** — Voice Memos recordings (opt-in) +- **GarageBand / Logic** — project files and caches (opt-in) +- **iMovie / Final Cut** — render files and libraries (opt-in) +- **Garmin / Fitbit** — device sync caches +- **Old Backups** — stale .backup files in Home, Desktop, Documents, Downloads +- **DNS Cache Flush** — flushes DNS resolver cache (command, sudo) +- **Font Cache** — rebuilds font databases (command, sudo) +- **Sleep Image** — removes /var/vm/sleepimage (command, sudo, opt-in) +- **Duplicate Files** — sha256 duplicate detection in ~/Documents, ~/Desktop, ~/Downloads, ~/Pictures, ~/Movies +- **Unused Apps** — apps not launched in 180+ days (scan-only) + +Cleanup tasks run in parallel across all available cores for maximum speed. All categories are always scanned. Dev-related ones show a purple "DEV" badge. Risk badges (Safe / Moderate / Dangerous / Protected) appear after scan — you pick what to delete, then confirm. + +**Cleanup Options** — toggles before scan: +- **Clean .DS_Store files** — removes Finder metadata from directories (off by default) +- **Clean iCloud Documents** — includes iCloud document cache (off by default) +- **Clean Voice Memos** — includes Voice Memos recordings (off by default) +- **Clean GarageBand / Logic** — includes project files and caches (off by default) +- **Clean iMovie / Final Cut** — includes render files and libraries (off by default) +- **Clean Sleep Image** — removes hibernation image file (off by default) -All categories are always scanned. Dev-related ones show a purple "DEV" badge. Risk badges (Safe / Moderate / Dangerous / Protected) appear after scan — you pick what to delete, then confirm. +**Process Manager** ⚙️ — redesigned with modern macOS styling. Lists running processes, lets you terminate or force-kill them. Critical system processes (kernel_task, launchd, WindowServer) are protected. -**Cleanup Options** — one opt-in toggle before scan: -- **Clean .DS_Store files** — removes Finder metadata from directories (off by default) +**Startup Services** 🚀 — redesigned with modern macOS styling. Shows all LaunchAgents from `~/Library/LaunchAgents`, their load status, and lets you enable/disable them. -**Process Manager** ⚙️ — lists running processes, lets you terminate or force-kill them. Critical system processes (kernel_task, launchd, WindowServer) are protected. +**App Uninstaller** 🗑️ — finds installed apps, scans 5 levels deep for residual files using 14 types of evidence (Bundle ID, Team ID, Spotlight, Plist contents, and more). Shows total reclaimable space and real-time scan progress. Tailored rules for over 95 popular apps including Docker, Parallels, Adobe CC, MS Office, Discord, Figma, and more. -**Startup Services** 🚀 — shows all LaunchAgents from `~/Library/LaunchAgents`, their load status, and lets you enable/disable them. +- **Background Deep Scanning** — apps are scanned thoroughly in the background; the UI updates in real time as each app's total size is finalized +- **Evidence-Based Forensics** — each candidate file is scored against 14 evidence types: identity, code signing, system integration, metadata, content analysis, graph relationships, and Launch Services registration +- **Confidence Tiers** — `.guaranteed` (critical evidence), `.veryLikely`, `.possible`, or `.ignore` +- **Developer Components** — detects and offers to clean Android SDK, Gradle/Maven, Xcode DerivedData, iOS Simulators, Flutter pub-cache, Docker containers, and Homebrew artifacts +- **Reveal in Finder** — quick action to show any related file or folder in Finder before deleting +- **Why this file?** — each related file includes an evidence breakdown with localized explanations. Tap any file to see exactly why it was associated with the app +- **Post-Uninstall Verification** — re-scan confirms cleanup completeness; snapshots stored for rollback + +**Settings** — rebuilt with native macOS `Form` styles to match System Settings. Light/dark/system theme, languages (English, Русский, Українська, Español), notifications, scan-on-startup, Trash behavior, and more. + +--- -**App Uninstaller** 🗑️ — finds installed apps, scans for residual files (Caches, Preferences, Application Support, Logs), shows total space to reclaim. Expert Mode for cherry-picking leftovers. +## 🐛 Bug Fixes in v1.1 -**Settings** — light/dark/system theme, languages (English, Русский, Українська), notifications, scan-on-startup, Trash behavior, and more. +- **Full Disk Access (FDA):** Fixed an issue where the FDA guidance window wouldn't appear on startup, and resolved false positives in permission checks by validating restricted directories +- **OrbStack Safety:** Accidentally deleting OrbStack paths from IDE caches — fixed. VM and Docker infrastructure are now protected +- **Duplicate Files:** Fixed inflated disk usage numbers in the duplicate files scanner +- **Xcode Previews:** Resolved a compilation issue preventing Xcode Canvas previews from rendering in Debug mode --- ## How It Works -Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories execute concurrently across all available cores. File scanning is done with a stack-based iterator that batches work and deduplicates inodes. Size calculations are cached to avoid redundant work. +Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories execute concurrently across all available cores. File scanning is done with a stack-based iterator that batches work and deduplicates inodes. Size calculations are cached to avoid redundant work. All cleanup paths are embedded as static Swift arrays — no runtime JSON parsing. --- @@ -111,15 +154,23 @@ Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories exe ``` MacOSCleaner/ -├ App/ # Entry point, RootView, sidebar navigation +├ App/ # Entry point, RootView, sidebar navigation ├ Domains/ -│ ├ Cleanup/ # Coordinator, Engine, StateMachine, ItemManager, Notifier, Models -│ ├ ProcessManagement/ # ProcessManager, ProcessSafetyPolicy -│ └ StartupServices/ # LaunchServiceManager -├ Features/ # SwiftUI views + ViewModels (Dashboard, Cleanup, Processes, Settings, Uninstaller, About) -├ Infrastructure/ # CommandRunner, SafetyManager, TrashManager, LanguageManager, PosixScanner, actors -├ Models/ # CleanupItem, OperationRisk, RunningProcess, StartupService, etc. -└ Resources/ # Localizable.strings (en/ru/uk), assets +│ ├ Cleanup/ # Coordinator, Engine, StateMachine, ItemManager, Notifier, Models, EmbeddedCleanupPaths +│ ├ ProcessManagement/ # ProcessManager, ProcessSafetyPolicy +│ └ StartupServices/ # LaunchServiceManager +├ Features/ +│ ├ Dashboard/ # DashboardView + ViewModel +│ ├ Cleanup/ # CleanupView + ViewModel, AnimatedScanView +│ ├ Processes/ # ProcessesView + ViewModel, ProcessRow +│ ├ Settings/ # SettingsView, AppSettings, StartupVendorSettings +│ ├ Uninstaller/ # 30 application rules, forensics engine, caches, UI +│ ├ StartupServices/ # StartupServicesView + ViewModel +│ ├ Permissions/ # PermissionsView +│ └ About/ # AboutView +├ Infrastructure/ # CommandRunner, SafetyManager, TrashManager, LanguageManager, PosixScanner, actors +├ Models/ # CleanupItem, OperationRisk, RunningProcess, StartupService, etc. +└ Resources/ # Localizable.strings (en/ru/uk/es), assets ``` --- diff --git a/assets/screenshots/About.png b/assets/screenshots/About.png index 9e9c811..37abe42 100644 Binary files a/assets/screenshots/About.png and b/assets/screenshots/About.png differ diff --git a/assets/screenshots/Cleanup.png b/assets/screenshots/Cleanup.png index 1d5ba2b..33dd61d 100644 Binary files a/assets/screenshots/Cleanup.png and b/assets/screenshots/Cleanup.png differ diff --git a/assets/screenshots/Cleanup_Scan.png b/assets/screenshots/Cleanup_Scan.png new file mode 100644 index 0000000..a3f6cdd Binary files /dev/null and b/assets/screenshots/Cleanup_Scan.png differ diff --git a/assets/screenshots/Cleanup_Scan_Results.png b/assets/screenshots/Cleanup_Scan_Results.png index 227532b..a620c40 100644 Binary files a/assets/screenshots/Cleanup_Scan_Results.png and b/assets/screenshots/Cleanup_Scan_Results.png differ diff --git a/assets/screenshots/Cleanup_scanning.png b/assets/screenshots/Cleanup_scanning.png deleted file mode 100644 index 77d70f9..0000000 Binary files a/assets/screenshots/Cleanup_scanning.png and /dev/null differ diff --git a/assets/screenshots/Dashboard.png b/assets/screenshots/Dashboard.png index 0a579e5..b006698 100644 Binary files a/assets/screenshots/Dashboard.png and b/assets/screenshots/Dashboard.png differ diff --git a/assets/screenshots/Permission_screen.png b/assets/screenshots/Permission_screen.png new file mode 100644 index 0000000..fb7f934 Binary files /dev/null and b/assets/screenshots/Permission_screen.png differ diff --git a/assets/screenshots/Processes.png b/assets/screenshots/Processes.png index 0925d5f..2be80ec 100644 Binary files a/assets/screenshots/Processes.png and b/assets/screenshots/Processes.png differ diff --git a/assets/screenshots/Settings.png b/assets/screenshots/Settings.png new file mode 100644 index 0000000..742bfcc Binary files /dev/null and b/assets/screenshots/Settings.png differ diff --git a/assets/screenshots/Settings_1.png b/assets/screenshots/Settings_1.png deleted file mode 100644 index f571d0c..0000000 Binary files a/assets/screenshots/Settings_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_2.png b/assets/screenshots/Settings_2.png deleted file mode 100644 index c86790f..0000000 Binary files a/assets/screenshots/Settings_2.png and /dev/null differ diff --git a/assets/screenshots/Startup_Services.png b/assets/screenshots/Startup_Services.png index d54093b..c25529f 100644 Binary files a/assets/screenshots/Startup_Services.png and b/assets/screenshots/Startup_Services.png differ diff --git a/assets/screenshots/Uninstaller.png b/assets/screenshots/Uninstaller.png index c5569aa..4c34937 100644 Binary files a/assets/screenshots/Uninstaller.png and b/assets/screenshots/Uninstaller.png differ diff --git a/assets/screenshots/Uninstaller_Expert Mode(select related files).png b/assets/screenshots/Uninstaller_Expert Mode(select related files).png deleted file mode 100644 index a78ab01..0000000 Binary files a/assets/screenshots/Uninstaller_Expert Mode(select related files).png and /dev/null differ diff --git a/assets/screenshots/Uninstaller_scan.png b/assets/screenshots/Uninstaller_scan.png new file mode 100644 index 0000000..eecbf37 Binary files /dev/null and b/assets/screenshots/Uninstaller_scan.png differ