From b91f0a021db663a8fadee61ebff1bd84bf797fe5 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 3 Jul 2026 12:02:05 +0300 Subject: [PATCH 1/2] feat: add app update checker and implement configurable scan safety modes --- MacOSCleaner/App/MacOSCleanerApp.swift | 10 +- MacOSCleaner/App/RootView.swift | 7 +- MacOSCleaner/Features/About/AboutView.swift | 37 +++++++- .../Features/Settings/AppSettings.swift | 30 +++++- .../Features/Settings/SettingsView.swift | 91 ++++++++++++++++++- .../Uninstaller/CandidateCollector.swift | 37 +++++--- .../Uninstaller/UninstallerService.swift | 16 ++-- .../Uninstaller/UninstallerView.swift | 23 ++++- .../Infrastructure/UpdateChecker.swift | 55 +++++++++++ .../MacOSCleaner.xcodeproj/project.pbxproj | 4 + .../Resources/en.lproj/Localizable.strings | 16 ++++ .../Resources/es.lproj/Localizable.strings | 16 ++++ .../Resources/ru.lproj/Localizable.strings | 16 ++++ .../Resources/uk.lproj/Localizable.strings | 16 ++++ 14 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 MacOSCleaner/Infrastructure/UpdateChecker.swift diff --git a/MacOSCleaner/App/MacOSCleanerApp.swift b/MacOSCleaner/App/MacOSCleanerApp.swift index d78fbc2..053d25b 100644 --- a/MacOSCleaner/App/MacOSCleanerApp.swift +++ b/MacOSCleaner/App/MacOSCleanerApp.swift @@ -15,6 +15,8 @@ struct MacOSCleanerApp: App { private let cleanupViewModel: CleanupViewModel private let appSettings = AppSettings() private let permissionsManager = PermissionsManager() + private let updateChecker = UpdateChecker() + @State private var availableUpdate: String? = nil init() { Self.installCrashHandlers() @@ -58,8 +60,12 @@ struct MacOSCleanerApp: App { cleanupViewModel: cleanupViewModel, journal: journal, appSettings: appSettings, - permissionsManager: permissionsManager + permissionsManager: permissionsManager, + availableUpdate: $availableUpdate ) + .task { + availableUpdate = await updateChecker.checkForUpdate() + } } .commands { CommandGroup(replacing: .appInfo) { @@ -70,7 +76,7 @@ struct MacOSCleanerApp: App { } Window("about_title".localized, id: "about") { - AboutView() + AboutView(availableUpdate: availableUpdate) } .windowResizability(.contentSize) .defaultPosition(.center) diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index bf01aae..56eb2b8 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -6,6 +6,7 @@ struct RootView: View { let journal: TransactionJournal let appSettings: AppSettings @Bindable var permissionsManager: PermissionsManager + @Binding var availableUpdate: String? var body: some View { NavigationSplitView { @@ -60,7 +61,8 @@ struct RootView: View { Task { try? await journal.clear() } - } + }, + availableUpdate: $availableUpdate ) } } @@ -78,6 +80,7 @@ struct RootView: View { ), journal: journal, appSettings: settings, - permissionsManager: PermissionsManager() + permissionsManager: PermissionsManager(), + availableUpdate: .constant(nil) ) } diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index 9434668..ecec158 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -3,6 +3,7 @@ import SwiftUI struct AboutView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme + var availableUpdate: String? = nil var body: some View { VStack(spacing: 0) { @@ -46,6 +47,10 @@ struct AboutView: View { private var content: some View { VStack(spacing: 16) { + if let update = availableUpdate { + updateBanner(version: update) + } + developerCard linksCard @@ -60,6 +65,36 @@ struct AboutView: View { .padding(20) } + private func updateBanner(version: String) -> some View { + Button { + NSWorkspace.shared.open(UpdateChecker.releasesURL) + } label: { + HStack(spacing: 12) { + Image(systemName: "bell.badge.fill") + .font(.title2) + .foregroundStyle(.white) + .symbolRenderingMode(.multicolor) + + VStack(alignment: .leading, spacing: 2) { + Text(String(format: "update.available".localized, version)) + .font(.headline) + .foregroundStyle(.white) + Text("update.download".localized + " →") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } + Spacer() + } + .padding(14) + .background( + LinearGradient(colors: [.blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: .blue.opacity(0.3), radius: 5, y: 2) + } + .buttonStyle(.plain) + } + private var developerCard: some View { HStack(spacing: 12) { Image(systemName: "person.circle.fill") @@ -97,5 +132,5 @@ struct AboutView: View { } #Preview { - AboutView() + AboutView(availableUpdate: "1.2.0") } diff --git a/MacOSCleaner/Features/Settings/AppSettings.swift b/MacOSCleaner/Features/Settings/AppSettings.swift index 72309d6..4da995c 100644 --- a/MacOSCleaner/Features/Settings/AppSettings.swift +++ b/MacOSCleaner/Features/Settings/AppSettings.swift @@ -70,6 +70,27 @@ public enum RefreshInterval: String, CaseIterable, Identifiable { } } +public enum ScanMode: String, CaseIterable, Identifiable, Sendable { + case safe + case balanced + + public var id: String { rawValue } + + public var localizedName: String { + switch self { + case .safe: return "scan_mode.safe".localized + case .balanced: return "scan_mode.balanced".localized + } + } + + public var localizedDescription: String { + switch self { + case .safe: return "scan_mode.safe.desc".localized + case .balanced: return "scan_mode.balanced.desc".localized + } + } +} + public enum ProcessSortOption: String, CaseIterable, Identifiable { case cpu = "CPU" case memory = "Memory" @@ -105,6 +126,7 @@ public final class AppSettings { static let emptyTrashImmediately = "settings_emptyTrashImmediately" static let processRefreshInterval = "settings_processRefreshInterval" static let processSortOption = "settings_processSortOption" + static let uninstallerScanMode = "settings_uninstallerScanMode" } // MARK: - General @@ -152,6 +174,10 @@ public final class AppSettings { didSet { UserDefaults.standard.set(bypassTrashOnUninstall, forKey: Keys.bypassTrashOnUninstall) } } + public var uninstallerScanMode: ScanMode { + didSet { UserDefaults.standard.set(uninstallerScanMode.rawValue, forKey: Keys.uninstallerScanMode) } + } + // MARK: - Advanced public var showRelatedFiles: Bool { @@ -193,6 +219,7 @@ public final class AppSettings { self.emptyTrashImmediately = defaults.bool(forKey: Keys.emptyTrashImmediately) self.processRefreshInterval = RefreshInterval(rawValue: defaults.string(forKey: Keys.processRefreshInterval) ?? "") ?? .manual self.processSortOption = ProcessSortOption(rawValue: defaults.string(forKey: Keys.processSortOption) ?? "") ?? .cpu + self.uninstallerScanMode = ScanMode(rawValue: defaults.string(forKey: Keys.uninstallerScanMode) ?? "") ?? .balanced LanguageManager.shared.setLanguage(lang) @@ -209,7 +236,7 @@ public final class AppSettings { Keys.language, Keys.theme, Keys.showNotifications, Keys.showTooltips, Keys.autoScanOnStartup, Keys.emptyTrashDuringCleanup, Keys.bypassTrashOnUninstall, Keys.showRelatedFiles, Keys.emptyTrashImmediately, - Keys.processRefreshInterval, Keys.processSortOption + Keys.processRefreshInterval, Keys.processSortOption, Keys.uninstallerScanMode ] for key in allKeys { defaults.removeObject(forKey: key) @@ -226,6 +253,7 @@ public final class AppSettings { emptyTrashImmediately = false processRefreshInterval = .manual processSortOption = .cpu + uninstallerScanMode = .balanced LanguageManager.shared.setLanguage(.english) } diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift index 82715a2..60dca86 100644 --- a/MacOSCleaner/Features/Settings/SettingsView.swift +++ b/MacOSCleaner/Features/Settings/SettingsView.swift @@ -7,15 +7,19 @@ struct SettingsView: View { @Bindable var settings: AppSettings let permissionsManager: PermissionsManager let onForget: () -> Void + @Binding var availableUpdate: String? + @State private var showResetConfirmation = false @State private var trashManager = TrashManager() @State private var notificationStatus: UNAuthorizationStatus = .notDetermined + @State private var isCheckingForUpdates = false var body: some View { Form { permissionsSection generalSection processesSection + uninstallerSection startupSection trashDeletionSection advancedSection @@ -112,6 +116,34 @@ struct SettingsView: View { Toggle("settings_auto_scan".localized, isOn: $settings.autoScanOnStartup) .tooltip("settings_tooltip_auto_scan".localized, enabled: settings.showTooltips) + + HStack { + Text("update.check".localized) + Spacer() + if isCheckingForUpdates { + ProgressView().controlSize(.small) + Text("update.checking".localized).foregroundStyle(.secondary) + } else if let version = availableUpdate { + Button { + NSWorkspace.shared.open(UpdateChecker.releasesURL) + } label: { + Text(String(format: "update.available".localized, version)) + } + .buttonStyle(.link) + } else { + Text("update.up_to_date".localized).foregroundStyle(.secondary) + Button { + Task { + isCheckingForUpdates = true + availableUpdate = await UpdateChecker().checkForUpdate() + isCheckingForUpdates = false + } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + } + } } header: { Label("settings_general".localized, systemImage: "gearshape") } @@ -139,6 +171,56 @@ struct SettingsView: View { } } + // MARK: - Uninstaller + + private var uninstallerSection: some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("scan_mode".localized, selection: $settings.uninstallerScanMode) { + ForEach(ScanMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } + } + .pickerStyle(.segmented) + + ForEach(ScanMode.allCases) { mode in + HStack(alignment: .top, spacing: 8) { + Image(systemName: settings.uninstallerScanMode == mode + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(settings.uninstallerScanMode == mode + ? (mode == .safe ? Color.blue : Color.green) + : Color.secondary) + .font(.caption) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(mode.localizedName) + .font(.callout) + .fontWeight(.medium) + if mode == .balanced { + Text("scan_mode.balanced.default".localized) + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.green.opacity(0.15)) + .foregroundStyle(.green) + .clipShape(Capsule()) + } + } + Text(mode.localizedDescription) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } header: { + Label("settings_uninstaller".localized, systemImage: "trash.slash") + } + } + // MARK: - Startup private var startupSection: some View { @@ -291,6 +373,11 @@ private extension View { } #Preview { - SettingsView(settings: AppSettings(), permissionsManager: PermissionsManager(), onForget: {}) - .frame(width: 700, height: 700) + SettingsView( + settings: AppSettings(), + permissionsManager: PermissionsManager(), + onForget: {}, + availableUpdate: .constant("1.5.0") + ) + .frame(width: 700, height: 700) } diff --git a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift index da4d42a..7c22b5e 100644 --- a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift +++ b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift @@ -9,9 +9,10 @@ public actor CandidateCollector { self.commandRunner = commandRunner } - public func collect(identity: AppIdentity) async -> Set { + public func collect(identity: AppIdentity, mode: ScanMode = .balanced) async -> Set { var candidates = Set() let home = NSHomeDirectory() + let maxDepth = mode == .safe ? 3 : 5 // 1. Fixed popular paths let basePaths = [ @@ -36,7 +37,7 @@ public actor CandidateCollector { for base in basePaths { let url = URL(fileURLWithPath: base) - candidates.formUnion(await shallowScan(url, identity: identity)) + candidates.formUnion(await shallowScan(url, identity: identity, mode: mode)) } // 2. Deep scan critical folders @@ -52,7 +53,7 @@ public actor CandidateCollector { ] for dir in deepFolders { let url = URL(fileURLWithPath: dir) - candidates.formUnion(await deepScan(url, identity: identity, depth: 0, maxDepth: 5)) + candidates.formUnion(await deepScan(url, identity: identity, depth: 0, maxDepth: maxDepth)) } // 3. pkgutil receipts @@ -67,9 +68,11 @@ public actor CandidateCollector { } } - // 4. mdfind - let mdfindCandidates = await runMdfind(identity: identity) - candidates.formUnion(mdfindCandidates) + // 4. mdfind (balanced only) + if mode == .balanced { + let mdfindCandidates = await runMdfind(identity: identity) + candidates.formUnion(mdfindCandidates) + } // 5. App-specific Electron paths if identity.isElectron { @@ -79,12 +82,12 @@ public actor CandidateCollector { } } - // 6. JetBrains-specific - if identity.isJetBrains { + // 6. JetBrains-specific (balanced only) + if mode == .balanced, 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)) + candidates.formUnion(await shallowScan(URL(fileURLWithPath: jbPath), identity: identity, mode: mode)) + candidates.formUnion(await deepScan(URL(fileURLWithPath: jbPath), identity: identity, depth: 0, maxDepth: maxDepth)) } } @@ -191,13 +194,13 @@ public actor CandidateCollector { return candidates } - private func shallowScan(_ url: URL, identity: AppIdentity) async -> Set { + private func shallowScan(_ url: URL, identity: AppIdentity, mode: ScanMode = .balanced) 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) { + if matchCandidate(item, identity: identity, mode: mode) { found.insert(item) } } @@ -211,7 +214,7 @@ public actor CandidateCollector { return found } for item in contents { - if matchCandidate(item, identity: identity) { + if matchCandidate(item, identity: identity, mode: .balanced) { found.insert(item) } var isDir: ObjCBool = false @@ -223,16 +226,22 @@ public actor CandidateCollector { return found } - private func matchCandidate(_ url: URL, identity: AppIdentity) -> Bool { + private func matchCandidate(_ url: URL, identity: AppIdentity, mode: ScanMode = .balanced) -> Bool { let name = url.lastPathComponent let lowerName = name.lowercased() + // Exact matches — always checked if name == identity.bundleID || name.hasPrefix(identity.bundleID + ".") { return true } if name == identity.appName || name.hasPrefix(identity.appName + " ") || name.hasPrefix(identity.appName + ".") { return true } + + // Safe mode: only exact matches above + if mode == .safe { return false } + + // Balanced: vendor, contains, executable matching if identity.vendorNames.contains(name) { return true } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index af1e03f..13ab58e 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -204,7 +204,7 @@ public actor UninstallerService { // MARK: - Deep Forensics - public func deepScan(_ app: AppInfo) async throws -> AppInfo { + public func deepScan(_ app: AppInfo, mode: ScanMode = .balanced) async throws -> AppInfo { let identity: AppIdentity if let existing = app.identity { identity = existing @@ -218,7 +218,7 @@ public actor UninstallerService { let graph = EvidenceGraph(identity: identity) - async let relatedTask: [RelatedFile] = runDeepRelatedFiles(identity: identity, graph: graph) + async let relatedTask: [RelatedFile] = runDeepRelatedFiles(identity: identity, graph: graph, mode: mode) async let developerTask: [RelatedCleanupComponent] = DeveloperComponentsDetector.detect( appName: identity.appName, bundleID: identity.bundleID @@ -232,9 +232,9 @@ public actor UninstallerService { return updated } - private func runDeepRelatedFiles(identity: AppIdentity, graph: EvidenceGraph) async -> [RelatedFile] { + private func runDeepRelatedFiles(identity: AppIdentity, graph: EvidenceGraph, mode: ScanMode = .balanced) async -> [RelatedFile] { let collector = CandidateCollector(commandRunner: commandRunner) - let candidates = await collector.collect(identity: identity) + let candidates = await collector.collect(identity: identity, mode: mode) let probe = EvidenceProbe(commandRunner: commandRunner, codesignCache: codesignCache, plistCache: plistCache) // Record evidence @@ -257,8 +257,11 @@ public actor UninstallerService { let assessments = ConfidenceEngine.assessAll(nodes, identity: identity) var related: [(RelatedFile, ConfidenceTier)] = [] + // Safe mode: only veryLikely and guaranteed; balanced: possible and above + let minimumTier: ConfidenceTier = mode == .safe ? .veryLikely : .possible + for (node, assessment) in zip(nodes, assessments) { - guard assessment.tier != .ignore else { continue } + guard assessment.tier >= minimumTier 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 } @@ -268,11 +271,10 @@ public actor UninstallerService { 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, + isSelected: true, size: fileSize, deletionRisk: risk, evidence: assessment.evidence, diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index 5ffd148..14fabca 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -107,6 +107,27 @@ struct UninstallerView: View { .navigationTitle("uninstaller_title".localized) .searchable(text: $searchText, placement: .toolbar, prompt: "uninstaller_search".localized) .toolbar { + ToolbarItem(placement: .automatic) { + // Scan mode badge + HStack(spacing: 4) { + Image(systemName: settings.uninstallerScanMode == .safe ? "shield.checkmark" : "scale.3d") + Text(settings.uninstallerScanMode.localizedName) + } + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .foregroundStyle( + settings.uninstallerScanMode == .safe ? Color.blue : Color.green + ) + .background( + Capsule().fill(settings.uninstallerScanMode == .safe ? Color.blue.opacity(0.1) : Color.green.opacity(0.1)) + ) + .overlay( + Capsule().strokeBorder(settings.uninstallerScanMode == .safe ? Color.blue.opacity(0.3) : Color.green.opacity(0.3), lineWidth: 1) + ) + .padding(.horizontal, 6) + } ToolbarItem(placement: .automatic) { Button(action: loadApps) { Image(systemName: "arrow.clockwise") @@ -167,7 +188,7 @@ struct UninstallerView: View { deepScanTotal = total for app in fresh { - if let result = try? await service.deepScan(app) { + if let result = try? await service.deepScan(app, mode: settings.uninstallerScanMode) { deepScanCompleted += 1 if let idx = allApps.firstIndex(where: { $0.url == result.url }) { allApps[idx] = result diff --git a/MacOSCleaner/Infrastructure/UpdateChecker.swift b/MacOSCleaner/Infrastructure/UpdateChecker.swift new file mode 100644 index 0000000..7a7f5ef --- /dev/null +++ b/MacOSCleaner/Infrastructure/UpdateChecker.swift @@ -0,0 +1,55 @@ +import Foundation +import OSLog + +private extension Logger { + static let updater = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "UpdateChecker") +} + +public actor UpdateChecker { + public static let releasesURL = URL(string: "https://github.com/AlexTkDev/MacOSCleaner/releases")! + private static let apiURL = URL(string: "https://api.github.com/repos/AlexTkDev/MacOSCleaner/releases/latest")! + + private struct Release: Decodable { + let tag_name: String + } + + public func checkForUpdate() async -> String? { + guard let localVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { + return nil + } + do { + var request = URLRequest(url: Self.apiURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } + let release = try JSONDecoder().decode(Release.self, from: data) + let remoteTag = release.tag_name + let remoteVersion = remoteTag.hasPrefix("v") ? String(remoteTag.dropFirst()) : remoteTag + if isNewer(remoteVersion, than: localVersion) { + Logger.updater.info("Update available: \(remoteVersion, privacy: .public) (current: \(localVersion, privacy: .public))") + return remoteVersion + } + return nil + } catch { + Logger.updater.error("Update check failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func isNewer(_ remote: String, than local: String) -> Bool { + let r = versionComponents(remote) + let l = versionComponents(local) + let count = max(r.count, l.count) + for i in 0.. lv } + } + return false + } + + private func versionComponents(_ version: String) -> [Int] { + version.split(separator: ".").compactMap { Int($0) } + } +} diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index e69fdf3..c874dce 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 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 */; }; + 28F537E447426249322A7AE8 /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC6DDA4FC825892B619F2CD /* UpdateChecker.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 */; }; @@ -319,6 +320,7 @@ 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 = ""; }; + BFC6DDA4FC825892B619F2CD /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.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 = ""; }; @@ -705,6 +707,7 @@ 0F6BA430DBA22418A7A0D0D8 /* String+Localization.swift */, 180E53446953349EB5FB2E04 /* SystemInfo.swift */, 93C5FD8CA29FB9E59A400223 /* TrashManager.swift */, + BFC6DDA4FC825892B619F2CD /* UpdateChecker.swift */, ); path = Infrastructure; sourceTree = ""; @@ -1014,6 +1017,7 @@ E656F0232A7C09D30D3A8B70 /* UninstallerService.swift in Sources */, 2005604ECBBC2DF0D8C637F0 /* UninstallerView.swift in Sources */, 7C4418AE4F14E22136AA7514 /* UnityRule.swift in Sources */, + 28F537E447426249322A7AE8 /* UpdateChecker.swift in Sources */, E698B04DFC7EFEF7435E7DC3 /* VMwareFusionRule.swift in Sources */, E741DC06901A23C9D9DC2267 /* VerificationEngine.swift in Sources */, 2D792A2983E94EDE263F97BC /* VerificationReport.swift in Sources */, diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 9f1967b..5ac98e3 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -82,6 +82,22 @@ "settings_forget_description" = "Clear all saved data and reset settings to their defaults."; "settings_reset_button" = "Reset All Settings"; +// Uninstaller Scan Mode +"settings_uninstaller" = "Uninstaller"; +"scan_mode" = "Scan Mode"; +"scan_mode.safe" = "Safe"; +"scan_mode.balanced" = "Balanced"; +"scan_mode.balanced.default" = "Default"; +"scan_mode.safe.desc" = "Finds only high-confidence files matched by Bundle ID, app name, or package receipts. Does not use Spotlight. Minimal risk of removing shared files."; +"scan_mode.balanced.desc" = "Full scan including Spotlight (mdfind), extended paths, and all possible matches. Finds more residuals including non-standard locations. Recommended for thorough cleanup."; + +// Update Checker +"update.check" = "Check for Updates"; +"update.available" = "Version %@ is available"; +"update.download" = "Download on GitHub"; +"update.up_to_date" = "Up to date"; +"update.checking" = "Checking..."; + // Settings Tooltips "settings_tooltip_language" = "Select the interface language for the application."; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 3113ce2..a9c6e3b 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -82,6 +82,22 @@ "settings_forget_description" = "Borrar todos los datos guardados y restablecer los ajustes a sus valores predeterminados."; "settings_reset_button" = "Restablecer todos los ajustes"; +// Uninstaller Scan Mode +"settings_uninstaller" = "Desinstalador"; +"scan_mode" = "Modo de análisis"; +"scan_mode.safe" = "Seguro"; +"scan_mode.balanced" = "Equilibrado"; +"scan_mode.balanced.default" = "Predeterminado"; +"scan_mode.safe.desc" = "Encuentra solo archivos con alta confianza de coincidencia: por Bundle ID, nombre de la app o recibos de paquetes. No usa Spotlight. Riesgo mínimo de eliminar archivos compartidos."; +"scan_mode.balanced.desc" = "Análisis completo: incluye Spotlight (mdfind), rutas extendidas y todas las coincidencias posibles. Encuentra más residuos, incluidos archivos en ubicaciones no estándar. Recomendado para una limpieza exhaustiva."; + +// Update Checker +"update.check" = "Buscar actualizaciones"; +"update.available" = "Versión %@ disponible"; +"update.download" = "Descargar en GitHub"; +"update.up_to_date" = "Versión actual"; +"update.checking" = "Comprobando..."; + // Settings Tooltips "settings_tooltip_language" = "Seleccione el idioma de la interfaz de la aplicación."; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 7f01e4d..728a867 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -82,6 +82,22 @@ "settings_forget_description" = "Очистить все сохраненные данные и восстановить настройки по умолчанию."; "settings_reset_button" = "Сбросить все настройки"; +// Uninstaller Scan Mode +"settings_uninstaller" = "Деинсталлятор"; +"scan_mode" = "Режим сканирования"; +"scan_mode.safe" = "Безопасный"; +"scan_mode.balanced" = "Сбалансированный"; +"scan_mode.balanced.default" = "По умолчанию"; +"scan_mode.safe.desc" = "Находит только файлы с высокой уверенностью совпадения: по Bundle ID, имени приложения или пакетным квитанциям. Не использует Spotlight. Минимальный риск затронуть файлы других приложений."; +"scan_mode.balanced.desc" = "Полный скан: включая Spotlight (mdfind), расширенные пути и все возможные совпадения. Находит больше остатков, включая файлы в нестандартных местах. Рекомендуется для максимальной чистоты."; + +// Update Checker +"update.check" = "Проверить обновления"; +"update.available" = "Доступна версия %@"; +"update.download" = "Скачать на GitHub"; +"update.up_to_date" = "Актуальная версия"; +"update.checking" = "Проверяем..."; + // Settings Tooltips "settings_tooltip_language" = "Выберите язык интерфейса приложения."; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 4b9f3c7..2428896 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -82,6 +82,22 @@ "settings_forget_description" = "Очистити всі збережені дані та відновити налаштування за замовчуванням."; "settings_reset_button" = "Скинути всі налаштування"; +// Uninstaller Scan Mode +"settings_uninstaller" = "Деінсталятор"; +"scan_mode" = "Режим сканування"; +"scan_mode.safe" = "Безпечний"; +"scan_mode.balanced" = "Збалансований"; +"scan_mode.balanced.default" = "За замовчуванням"; +"scan_mode.safe.desc" = "Знаходить лише файли з високою впевненістю збігу: за Bundle ID, назвою програми або пакетними квитанціями. Не використовує Spotlight. Мінімальний ризик зачепити файли інших програм."; +"scan_mode.balanced.desc" = "Повне сканування: включаючи Spotlight (mdfind), розширені шляхи та всі можливі збіги. Знаходить більше залишків, включаючи файли в нестандартних місцях. Рекомендується для максимального очищення."; + +// Update Checker +"update.check" = "Перевірити оновлення"; +"update.available" = "Доступна версія %@"; +"update.download" = "Завантажити на GitHub"; +"update.up_to_date" = "Актуальна версія"; +"update.checking" = "Перевіряємо..."; + // Settings Tooltips "settings_tooltip_language" = "Виберіть мову інтерфейсу додатка."; From ee603106211afe44479d774863c1dc7e9e1cdd18 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 3 Jul 2026 12:07:25 +0300 Subject: [PATCH 2/2] chore: bump version to 1.1.1 and update release documentation --- .../MacOSCleaner.xcodeproj/project.pbxproj | 4 ++-- MacOSCleaner/project.yml | 2 +- README.md | 24 +++++++------------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index c874dce..3c053a4 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -1074,7 +1074,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = singlefile; @@ -1169,7 +1169,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index 9ac0b7b..a200be1 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -20,7 +20,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleaner - MARKETING_VERSION: 1.1 + MARKETING_VERSION: 1.1.1 CURRENT_PROJECT_VERSION: 1 ENABLE_HARDENED_RUNTIME: YES SWIFT_VERSION: 6.0 diff --git a/README.md b/README.md index 33ffa0b..3e82932 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Language: Swift 6](https://img.shields.io/badge/Language-Swift%206-FA7343.svg?logo=swift&logoColor=white)](https://swift.org) [![UI: SwiftUI](https://img.shields.io/badge/UI-SwiftUI-007AFF.svg?logo=swift&logoColor=white)](https://developer.apple.com/xcode/swiftui/) [![Build: XcodeGen](https://img.shields.io/badge/Build-XcodeGen-black.svg?logo=xcode&logoColor=white)](https://github.com/yonaskolb/XcodeGen) -[![Version: 1.1](https://img.shields.io/badge/Release-1.1-brightgreen.svg)]() +[![Version: 1.1.1](https://img.shields.io/badge/Release-1.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,16 +18,16 @@ ## Screenshots

- - + +

- - + +

- 📷 View all screenshots + 📷 View all screenshots

--- @@ -102,6 +102,7 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All **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. +- **Scan Modes (Safe / Balanced)** — choose between *Safe* mode (exact matches only, no Spotlight, highest confidence files) and *Balanced* mode (full deep scan including Spotlight and fuzzy matching) to tailor uninstallation aggressiveness - **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` @@ -110,16 +111,9 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All - **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. - ---- +**Smart Updates** 🔄 — automatic, lightweight background check for new versions on startup directly via GitHub Releases. Get gently notified when a new update is ready, without background daemons, persistent tracking, or extra dependencies. -## 🐛 Bug Fixes in v1.1 - -- **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 +**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. ---