Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ message: >-
(non-commercial) in LICENSE. Commercial use requires a separate license.
type: software
title: MacOS Cleaner
version: "2.1.1"
date-released: "2026-08-12"
version: "2.2.0"
date-released: "2026-08-26"
url: "https://github.com/AlexTkDev/MacOSCleaner"
repository-code: "https://github.com/AlexTkDev/MacOSCleaner"
# SPDX has no Commons Clause compound; see LICENSE for the binding terms.
Expand Down
9 changes: 7 additions & 2 deletions DONATE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Support My Work
Thank you for supporting my projects!

Thank you for supporting my projects.
## ☕ Ko-fi
Support via Card, Apple Pay, or PayPal:
**[https://ko-fi.com/alextkdev](https://ko-fi.com/alextkdev)**

## Recommended
---

## 💎 Cryptocurrency

### USDT (TRC20)
TAQBrzZuAvJ5Zga7touVzNGXXJykEVp7sp
Expand Down
6 changes: 6 additions & 0 deletions MacOSCleaner/App/MacOSCleanerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ struct MacOSCleanerApp: App {
}
}
.disabled(isCheckingForUpdates)

Button("menu_donate".localized) {
if let url = URL(string: "https://github.com/AlexTkDev/MacOSCleaner/blob/main/DONATE.md") {
NSWorkspace.shared.open(url)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ extension CleanupCategory {
labels.insert("Duplicate Files")
case .unusedApps:
labels.insert("Unused Apps")
case .projectBuildArtifacts:
labels.insert("Project build artifacts")
labels.insert("Project-local build artifacts")
}

return labels
Expand Down Expand Up @@ -220,6 +223,7 @@ extension CleanupCategory {
case "itunes_backup_scanner": return .iosBackups
case "spotlight_index_scanner": return .systemCaches
case "swap_files_scanner": return .systemCaches
case "project_build_artifacts_scanner": return .projectBuildArtifacts
default: return nil
}
}
Expand Down
5 changes: 3 additions & 2 deletions MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ public final class CleanupCoordinator: @unchecked Sendable {
let categories = self.itemManager.selectedCleanupCategories(from: options.categories())
// Review-only categories — never run category-level wipe.
let safeCategories = categories.filter {
$0 != .oldBackups && $0 != .aiModels && $0 != .installerPackages && $0 != .largeFiles
$0 != .oldBackups && $0 != .aiModels && $0 != .installerPackages && $0 != .largeFiles && $0 != .projectBuildArtifacts
}
var records: [OperationRecord] = []
var hadPartialFailure = false
Expand Down Expand Up @@ -210,6 +210,7 @@ public final class CleanupCoordinator: @unchecked Sendable {
(.aiModels, "AI Models"),
(.installerPackages, "Installer Packages"),
(.largeFiles, "Large files"),
(.projectBuildArtifacts, "Project build artifacts"),
]
for (category, logLabel) in reviewGroups {
let selectedURLs = self.selectedReviewLeafURLs(for: category)
Expand Down Expand Up @@ -357,7 +358,7 @@ public final class CleanupCoordinator: @unchecked Sendable {

@MainActor
private func deselectReviewOnlyGroups() {
for category in [CleanupCategory.oldBackups, .aiModels, .installerPackages, .largeFiles] {
for category in [CleanupCategory.oldBackups, .aiModels, .installerPackages, .largeFiles, .projectBuildArtifacts] {
for label in category.previewLabels {
itemManager.setSelection(underParentLabel: label, isSelected: false)
}
Expand Down
163 changes: 96 additions & 67 deletions MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public enum CleanupCategory: String, CaseIterable, Sendable {
case sleepImage = "sleep_image"
case duplicateFiles = "duplicate_files"
case unusedApps = "unused_apps"
case projectBuildArtifacts = "project_build_artifacts"
}

// MARK: - CleanupEngine Actor
Expand Down Expand Up @@ -350,6 +351,7 @@ public actor CleanupEngine {
case .sleepImage: return try await cleanSleepImage(dryRun: dryRun, progress: progress)
case .duplicateFiles: return try await cleanDuplicateFiles(dryRun: dryRun, progress: progress)
case .unusedApps: return try await cleanUnusedApps(dryRun: dryRun, progress: progress)
case .projectBuildArtifacts: return try await cleanProjectBuildArtifacts(dryRun: dryRun, progress: progress, olderThanDays: options.projectArtifactsOlderThanDays)
}
}

Expand Down Expand Up @@ -454,6 +456,10 @@ public struct CleanupOptions: Sendable, Equatable {
public var cleanModCache: Bool = true
/// When true, deep cleans project artifacts (.dart_tool directories).
public var cleanProjects: Bool = true
/// When true, cleans project-local build artifacts. Default is false (review-only).
public var cleanProjectArtifacts: Bool = false
/// Project build artifacts older than this many days will be cleaned (default: 60).
public var projectArtifactsOlderThanDays: Int = 60
/// Xcode Archives older than this many days will be cleaned.
public var xcodeArchivesOlderThanDays: Int = 90
/// When true, cleans CloudDocs (iCloud document cache).
Expand All @@ -469,11 +475,27 @@ public struct CleanupOptions: Sendable, Equatable {
/// When true, cleans Time Machine local snapshots.
public var cleanTimeMachineSnapshots: Bool = false

public init(cleanDSStore: Bool = false, cleanMaven: Bool = true, cleanModCache: Bool = true, cleanProjects: Bool = true, xcodeArchivesOlderThanDays: Int = 90, cleanCloudDocs: Bool = false, cleanVoiceMemos: Bool = false, cleanGarageBandLogic: Bool = false, cleanIMovieFinalCut: Bool = false, cleanSleepImage: Bool = false, cleanTimeMachineSnapshots: Bool = false) {
public init(
cleanDSStore: Bool = false,
cleanMaven: Bool = true,
cleanModCache: Bool = true,
cleanProjects: Bool = true,
cleanProjectArtifacts: Bool = false,
projectArtifactsOlderThanDays: Int = 60,
xcodeArchivesOlderThanDays: Int = 90,
cleanCloudDocs: Bool = false,
cleanVoiceMemos: Bool = false,
cleanGarageBandLogic: Bool = false,
cleanIMovieFinalCut: Bool = false,
cleanSleepImage: Bool = false,
cleanTimeMachineSnapshots: Bool = false
) {
self.cleanDSStore = cleanDSStore
self.cleanMaven = cleanMaven
self.cleanModCache = cleanModCache
self.cleanProjects = cleanProjects
self.cleanProjectArtifacts = cleanProjectArtifacts
self.projectArtifactsOlderThanDays = projectArtifactsOlderThanDays
self.xcodeArchivesOlderThanDays = xcodeArchivesOlderThanDays
self.cleanCloudDocs = cleanCloudDocs
self.cleanVoiceMemos = cleanVoiceMemos
Expand All @@ -494,7 +516,7 @@ public struct CleanupOptions: Sendable, Equatable {
/// per-item ownership proofs and explicit user selection:
/// orphaned remnants/files, old backups, AI/LLM user_content, installer packages,
/// large-file review items, launch agents/daemons,
/// privileged helpers, package receipts, internet plugins.
/// privileged helpers, package receipts, internet plugins, project build artifacts.
public func categories() -> [CleanupCategory] {
var categories: [CleanupCategory] = [
.appCaches,
Expand Down Expand Up @@ -544,6 +566,9 @@ public struct CleanupOptions: Sendable, Equatable {
if cleanCloudDocs {
categories.append(.cloudDocs)
}
if cleanProjectArtifacts {
categories.append(.projectBuildArtifacts)
}
if cleanVoiceMemos {
categories.append(.voiceMemos)
}
Expand Down Expand Up @@ -1070,23 +1095,43 @@ extension CleanupEngine {
freed += af
if dryRun { emitFileItem(ai, category: "Xcode", parentName: nil, progress: progress) }

// Project-local build artifacts (DerivedData, build/ inside project repos)
let projectLocalFreed = try await cleanProjectLocalBuildArtifacts(home: home, dryRun: dryRun, progress: progress)
freed += projectLocalFreed

let mb = Int(freed / (1024 * 1024))
progress?(.log("Xcode total: \(Self.formatBytes(freed))"))
progress?(.result(label: "Xcode cleanup", freedMB: mb))
return [CleanupEngineResult(label: "Xcode", freedMB: mb)]
}

// MARK: - Project Build Artifacts

func cleanProjectBuildArtifacts(
dryRun: Bool,
progress: (@Sendable (CleanupEngineEvent) -> Void)?,
olderThanDays: Int = 60
) async throws -> [CleanupEngineResult] {
let home = fileSystemContext.homePath
progress?(.log("Scanning project build artifacts (older than \(olderThanDays) days)..."))
let freed = try await cleanProjectLocalBuildArtifacts(
home: home,
dryRun: dryRun,
progress: progress,
olderThanDays: olderThanDays
)
let mb = Int(freed / (1024 * 1024))
progress?(.log("Project build artifacts total: \(Self.formatBytes(freed))"))
progress?(.result(label: "Project build artifacts", freedMB: mb))
return [CleanupEngineResult(label: "Project build artifacts", freedMB: mb)]
}

/// Scans common developer directories for project-local build artifacts.
/// Filters by mtime of the artifact directory (older than olderThanDays).
/// All targets are 100% regenerable by their respective build tools.
private func cleanProjectLocalBuildArtifacts(
home: String,
dryRun: Bool,
progress: (@Sendable (CleanupEngineEvent) -> Void)?
progress: (@Sendable (CleanupEngineEvent) -> Void)?,
olderThanDays: Int = 60
) async throws -> Int64 {
let cutoffDate = Calendar.current.date(byAdding: .day, value: -olderThanDays, to: Date()) ?? Date.distantPast
let searchRoots = [
"\(home)/Documents",
"\(home)/Developer",
Expand Down Expand Up @@ -1188,11 +1233,17 @@ extension CleanupEngine {
// Check if this is an always-removable artifact
if alwaysRemovable.contains(name) {
enumerator.skipDescendants()
if let attrs = try? fm.attributesOfItem(atPath: url.path),
let modDate = attrs[.modificationDate] as? Date,
modDate > cutoffDate {
// Modified recently (< olderThanDays), keep it
continue
}
do {
let (f, item) = try await removeDirectory(url.path, dryRun: dryRun, progress: progress)
freed += f
foundCount += 1
if dryRun { emitFileItem(item, category: "Xcode", parentName: "Project build artifacts", progress: progress) }
if dryRun { emitFileItem(item, category: "Project build artifacts", parentName: "Project build artifacts", progress: progress) }
} catch is SafetyError {
progress?(.log(" \(shortPath(url.path)) — protected, skipped"))
}
Expand Down Expand Up @@ -1221,11 +1272,17 @@ extension CleanupEngine {
guard matched else { continue }

enumerator.skipDescendants()
if let attrs = try? fm.attributesOfItem(atPath: url.path),
let modDate = attrs[.modificationDate] as? Date,
modDate > cutoffDate {
// Modified recently (< olderThanDays), keep it
continue
}
do {
let (f, item) = try await removeDirectory(url.path, dryRun: dryRun, progress: progress)
freed += f
foundCount += 1
if dryRun { emitFileItem(item, category: "Xcode", parentName: "Project build artifacts", progress: progress) }
if dryRun { emitFileItem(item, category: "Project build artifacts", parentName: "Project build artifacts", progress: progress) }
} catch is SafetyError {
progress?(.log(" \(shortPath(url.path)) — protected, skipped"))
}
Expand Down Expand Up @@ -2183,44 +2240,6 @@ extension CleanupEngine {
progress?(.log(" Checked \(scannedCount) files in \(downloadDir.replacingOccurrences(of: home, with: "~"))"))
}

// node_modules directories > 100MB (recursive search)
progress?(.log(" Scanning home directory for node_modules > 100 MB..."))
let searchDirs = [home]
for baseDir in searchDirs {
guard fm.fileExists(atPath: baseDir) else { continue }
guard let enumerator = fm.enumerator(atPath: baseDir) else { continue }
while let item = enumerator.nextObject() as? String {
try Task.checkCancellation()
let fullPath = "\(baseDir)/\(item)"
// Skip hidden dirs, Library
if item.hasPrefix(".") || fullPath.contains("/Library/") {
enumerator.skipDescendants()
continue
}
// Skip heavy directories (Docker, VMs, etc.) — only for actual directories
if Self.isHeavyDirectory(fullPath) {
var isDir: ObjCBool = false
fm.fileExists(atPath: fullPath, isDirectory: &isDir)
if isDir.boolValue {
progress?(.log(" Skipping heavy directory: \(shortPath(fullPath))"))
enumerator.skipDescendants()
}
continue
}
if item == "node_modules" {
let size = await getDirectorySizeWithTimeout(fullPath, timeout: .seconds(10))
if size > 100 * 1024 * 1024 {
items.append(("\(fullPath.replacingOccurrences(of: home, with: "~"))", size))
totalFound += size
if dryRun {
emitFileItem(CleanupFileItem(path: fullPath, sizeBytes: size, modificationDate: nil, isDirectory: true), category: "Large files", parentName: "Large files", progress: progress)
}
}
enumerator.skipDescendants()
}
}
}

// IPSW firmware files
progress?(.log(" Scanning for IPSW firmware files..."))
let ipswSearchDirs = [home, "/tmp"]
Expand Down Expand Up @@ -2369,32 +2388,41 @@ extension CleanupEngine {
}

let snapshots = await TimeMachineScanner.listLocalSnapshots()
let purgeableMB = TimeMachineScanner.getPurgeableSpaceMB()

progress?(.log(" Found \(snapshots.count) local snapshots"))
progress?(.log(" Found \(snapshots.count) local snapshots (purgeable: ~\(purgeableMB) MB)"))

if dryRun {
for snap in snapshots {
progress?(.log(" ⊘ \(snap.name)"))
}
progress?(.result(label: "Time Machine Snapshots", freedMB: purgeableMB))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: purgeableMB)]
}

if snapshots.isEmpty {
progress?(.log(" No local snapshots to thin"))
progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}

var deleted = 0
for snap in snapshots {
let availableBefore = (try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityKey]))?.volumeAvailableCapacity ?? 0
let purgeBytes = max(10_000_000_000, Int64(purgeableMB) * 1024 * 1024)

do {
try Task.checkCancellation()
do {
_ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/tmutil deletelocalsnapshots \(snap.name)")
deleted += 1
progress?(.log(" ✓ Deleted \(snap.name)"))
} catch {
progress?(.log(" ✗ Failed to delete \(snap.name): \(error.localizedDescription)"))
}
_ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/tmutil thinlocalsnapshots / \(purgeBytes) 4")
let availableAfter = (try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityKey]))?.volumeAvailableCapacity ?? 0
let freedBytes = max(0, availableAfter - availableBefore)
let freedMB = Int(freedBytes / (1024 * 1024))
progress?(.log(" ✓ Thinned local snapshots (freed ~\(freedMB) MB)"))
progress?(.result(label: "Time Machine Snapshots", freedMB: freedMB))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: freedMB, freedBytes: Int64(freedBytes))]
} catch {
progress?(.log(" ✗ Failed to thin snapshots: \(error.localizedDescription)"))
progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}

progress?(.log(" Deleted \(deleted) snapshots"))
progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}

// MARK: 24. iOS Backups
Expand Down Expand Up @@ -3086,15 +3114,16 @@ extension CleanupEngine {
func cleanDNSFlush(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] {
progress?(.log("Flushing DNS cache..."))
if dryRun {
progress?(.log(" Would run: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder"))
progress?(.log(" Would run: dscacheutil -flushcache && killall -HUP mDNSResponder"))
progress?(.result(label: "DNS Cache", freedMB: 0))
return [CleanupEngineResult(label: "DNS Cache", freedMB: 0)]
}
let result = try? await commandRunner.run(command: "/bin/bash", arguments: ["-c", "sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder"])
if result?.exitCode == 0 {
progress?(.log(" DNS cache flushed successfully"))
} else {
progress?(.log(" DNS cache flush failed (may need sudo without password)"))
do {
try Task.checkCancellation()
_ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/dscacheutil -flushcache; /usr/bin/killall -HUP mDNSResponder")
progress?(.log(" ✓ DNS cache flushed successfully"))
} catch {
progress?(.log(" ✗ DNS cache flush failed: \(error.localizedDescription)"))
}
progress?(.result(label: "DNS Cache", freedMB: 0))
return [CleanupEngineResult(label: "DNS Cache", freedMB: 0)]
Expand Down
11 changes: 10 additions & 1 deletion MacOSCleaner/Features/About/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ struct AboutView: View {

private var linksCard: some View {
VStack(spacing: 0) {
Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/blob/main/DONATE.md")!) {
Label("about_donate".localized, systemImage: "heart.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.pink)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.contentShape(Rectangle())
}
Divider().padding(.leading, 38)
Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner")!) {
Label("about_star_github".localized, systemImage: "star.fill")
.font(.subheadline.weight(.semibold))
Expand Down Expand Up @@ -169,5 +178,5 @@ struct AboutView: View {
}

#Preview {
AboutView(availableUpdate: AvailableUpdate(version: "2.1.1", dmgURL: nil))
AboutView(availableUpdate: AvailableUpdate(version: "2.2.0", dmgURL: nil))
}
Loading
Loading