puremac-macos-cleaner
PureMac macOS Cleaner
Skill by ara.so — Daily 2026 Skills collection.
PureMac is a free, native SwiftUI macOS application that cleans system junk, user caches, Xcode derived data, Homebrew caches, mail attachments, and purgeable APFS space. It is a privacy-respecting, open-source alternative to CleanMyMac X with no telemetry, no subscriptions, and no network calls.
Install
Homebrew (recommended)
brew tap momenbasel/tap
brew install --cask puremac
Direct Download
Download the latest .app from Releases, unzip, and drag to /Applications.
Build from Source
brew install xcodegen
git clone https://github.com/momenbasel/PureMac.git
cd PureMac
xcodegen generate
xcodebuild \
-project PureMac.xcodeproj \
-scheme PureMac \
-configuration Release \
-derivedDataPath build \
build
open build/Build/Products/Release/PureMac.app
Requirements: macOS 13.0+, Swift 5.9, Xcode 15+.
Project Structure
PureMac/
├── PureMac/
│ ├── App/
│ │ └── PureMacApp.swift # App entry point
│ ├── Views/
│ │ ├── ContentView.swift # Main window
│ │ ├── ScanView.swift # Smart scan UI
│ │ ├── CategoryDetailView.swift # Per-category drill-down
│ │ └── SettingsView.swift # Schedule & preferences
│ ├── Models/
│ │ ├── CleanCategory.swift # Category definitions
│ │ └── ScanResult.swift # Scan result model
│ ├── Services/
│ │ ├── ScannerService.swift # File scanning logic
│ │ ├── CleanerService.swift # Deletion logic
│ │ ├── SchedulerService.swift # Auto-clean scheduling
│ │ └── PurgeableService.swift # APFS purgeable space
│ └── Utilities/
│ └── FileSizeFormatter.swift
├── project.yml # XcodeGen spec
└── CONTRIBUTING.md
Core Concepts
Clean Categories
PureMac operates on named categories, each mapping to specific filesystem paths:
| Category | Key Paths |
|---|---|
| System Junk | /Library/Caches, /Library/Logs, /tmp, ~/Library/Logs |
| User Cache | ~/Library/Caches, npm/pip/yarn/pnpm caches |
| Mail Attachments | ~/Library/Mail Downloads |
| Trash | ~/.Trash |
| Large & Old Files | ~/Downloads, ~/Documents, ~/Desktop (>100 MB or >1 year old) |
| Purgeable Space | APFS Time Machine snapshots via tmutil |
| Xcode Junk | DerivedData, Archives, CoreSimulator/Caches |
| Homebrew Cache | ~/Library/Caches/Homebrew |
Large & Old Files are never auto-selected — the user must explicitly choose items before cleaning.
Working with the Codebase
Adding a New Clean Category
- Define the category in
CleanCategory.swift:
// CleanCategory.swift
enum CleanCategory: String, CaseIterable, Identifiable {
case systemJunk = "System Junk"
case userCache = "User Cache"
case mailAttachments = "Mail Attachments"
case trash = "Trash"
case largeOldFiles = "Large & Old Files"
case purgeableSpace = "Purgeable Space"
case xcodeJunk = "Xcode Junk"
case homebrewCache = "Homebrew Cache"
// Add your new category here:
case gradleCache = "Gradle Cache"
var id: String { rawValue }
var iconName: String {
switch self {
case .systemJunk: return "trash.circle"
case .userCache: return "internaldrive"
case .xcodeJunk: return "hammer"
case .homebrewCache: return "shippingbox"
case .gradleCache: return "archivebox" // new
default: return "folder"
}
}
}
- Add scanning logic in
ScannerService.swift:
// ScannerService.swift
func scanCategory(_ category: CleanCategory) async throws -> ScanResult {
switch category {
case .gradleCache:
return try await scanPaths([
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".gradle/caches")
])
// ...existing cases
default:
throw ScannerError.unsupportedCategory
}
}
private func scanPaths(_ urls: [URL]) async throws -> ScanResult {
var files: [ScannedFile] = []
let fm = FileManager.default
for url in urls {
guard fm.fileExists(atPath: url.path) else { continue }
let enumerator = fm.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
)
while let fileURL = enumerator?.nextObject() as? URL {
let values = try fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
let size = Int64(values.fileSize ?? 0)
let modified = values.contentModificationDate ?? Date.distantPast
files.append(ScannedFile(url: fileURL, size: size, modifiedDate: modified))
}
}
let totalBytes = files.reduce(0) { $0 + $1.size }
return ScanResult(category: .gradleCache, files: files, totalBytes: totalBytes)
}
- Add cleaning logic in
CleanerService.swift:
// CleanerService.swift
func clean(_ result: ScanResult, selectedFiles: Set<URL>? = nil) async throws -> Int64 {
let filesToDelete = selectedFiles.map { Array($0) } ?? result.files.map(\.url)
var bytesFreed: Int64 = 0
let fm = FileManager.default
for url in filesToDelete {
do {
let attrs = try fm.attributesOfItem(atPath: url.path)
let size = attrs[.size] as? Int64 ?? 0
try fm.removeItem(at: url)
bytesFreed += size
} catch {
// Log but continue — don't abort on single-file failure
print("Failed to delete \(url.lastPathComponent): \(error.localizedDescription)")
}
}
return bytesFreed
}
Scheduled Auto-Cleaning
Configure via Settings → Schedule tab. Intervals: hourly, 3h, 6h, 12h, daily, weekly, biweekly, monthly.
// SchedulerService.swift — how scheduling is implemented
import UserNotifications
class SchedulerService: ObservableObject {
@AppStorage("schedulingEnabled") var schedulingEnabled: Bool = false
@AppStorage("cleaningInterval") var cleaningInterval: String = "daily"
@AppStorage("autoCleanAfterScan") var autoCleanAfterScan: Bool = false
@AppStorage("autoPurgePurgeable") var autoPurgePurgeable: Bool = false
private var timer: Timer?
func scheduleIfNeeded() {
timer?.invalidate()
guard schedulingEnabled else { return }
let interval = intervalSeconds(for: cleaningInterval)
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { await self?.runScheduledClean() }
}
}
private func intervalSeconds(for key: String) -> TimeInterval {
switch key {
case "hourly": return 3_600
case "3h": return 10_800
case "6h": return 21_600
case "12h": return 43_200
case "daily": return 86_400
case "weekly": return 604_800
case "biweekly": return 1_209_600
case "monthly": return 2_592_000
default: return 86_400
}
}
@MainActor
private func runScheduledClean() async {
let scanner = ScannerService()
let cleaner = CleanerService()
for category in CleanCategory.allCases where category != .largeOldFiles {
if let result = try? await scanner.scanCategory(category), autoCleanAfterScan {
_ = try? await cleaner.clean(result)
}
}
if autoPurgePurgeable {
try? await PurgeableService.shared.purge()
}
}
}
Enable scheduling programmatically:
let scheduler = SchedulerService()
scheduler.cleaningInterval = "weekly"
scheduler.autoCleanAfterScan = true
scheduler.autoPurgePurgeable = false
scheduler.schedulingEnabled = true
scheduler.scheduleIfNeeded()
Purgeable Space (APFS Snapshots)
PureMac uses tmutil to delete local Time Machine snapshots — this is the only operation requiring elevated privileges:
// PurgeableService.swift
import Foundation
class PurgeableService {
static let shared = PurgeableService()
func listSnapshots() async throws -> [String] {
let output = try await shell("tmutil listlocalsnapshots /")
return output
.split(separator: "\n")
.map(String.init)
.filter { $0.hasPrefix("com.apple.TimeMachine") }
}
func purge() async throws {
let snapshots = try await listSnapshots()
for snapshot in snapshots {
try await shell("tmutil deletelocalsnapshots \(snapshot)")
}
}
@discardableResult
private func shell(_ command: String) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
let task = Process()
task.launchPath = "/bin/bash"
task.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.terminationHandler = { _ in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
continuation.resume(returning: String(data: data, encoding: .utf8) ?? "")
}
do { try task.run() } catch { continuation.resume(throwing: error) }
}
}
}
Xcode Cache Paths
// Paths cleaned by the Xcode Junk category
let home = FileManager.default.homeDirectoryForCurrentUser
let xcodePaths: [URL] = [
home.appendingPathComponent("Library/Developer/Xcode/DerivedData"),
home.appendingPathComponent("Library/Developer/Xcode/Archives"),
home.appendingPathComponent("Library/Developer/CoreSimulator/Caches"),
]
Scan these and safely delete their contents without removing the directories themselves.
SwiftUI View Patterns
Scan Progress View
// Example: triggering a scan from a SwiftUI view
struct ScanView: View {
@StateObject private var scanner = ScannerService()
@State private var results: [ScanResult] = []
@State private var isScanning = false
var body: some View {
VStack {
if isScanning {
ProgressView("Scanning…")
} else {
Button("Smart Scan") {
Task { await runScan() }
}
}
List(results, id: \.category) { result in
CategoryRow(result: result)
}
}
}
private func runScan() async {
isScanning = true
results = []
for category in CleanCategory.allCases {
if let result = try? await scanner.scanCategory(category) {
results.append(result)
}
}
isScanning = false
}
}
File Inspector (Click-to-Inspect)
// Show files before deletion — users can deselect
struct CategoryDetailView: View {
let result: ScanResult
@State private var selected: Set<URL> = []
@State private var cleaned = false
var body: some View {
List(result.files, id: \.url, selection: $selected) { file in
HStack {
Image(systemName: "doc")
Text(file.url.lastPathComponent)
Spacer()
Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
.foregroundStyle(.secondary)
}
}
.toolbar {
Button("Clean Selected") {
Task {
let cleaner = CleanerService()
_ = try? await cleaner.clean(result, selectedFiles: selected)
cleaned = true
}
}
.disabled(selected.isEmpty)
}
}
}
Configuration (AppStorage Keys)
All preferences are stored in UserDefaults via @AppStorage:
| Key | Type | Default | Description |
|---|---|---|---|
schedulingEnabled |
Bool | false | Enable scheduled cleaning |
cleaningInterval |
String | "daily" | Interval key (see above) |
autoCleanAfterScan |
Bool | false | Auto-clean after scheduled scan |
autoPurgePurgeable |
Bool | false | Auto-purge APFS snapshots |
Read/write from anywhere:
UserDefaults.standard.set(true, forKey: "schedulingEnabled")
UserDefaults.standard.set("weekly", forKey: "cleaningInterval")
Building & Testing
# Generate Xcode project from project.yml
xcodegen generate
# Build Release
xcodebuild \
-project PureMac.xcodeproj \
-scheme PureMac \
-configuration Release \
-derivedDataPath build \
build
# Run tests
xcodebuild test \
-project PureMac.xcodeproj \
-scheme PureMac \
-destination 'platform=macOS'
# Open built app
open build/Build/Products/Release/PureMac.app
Contributing
- Fork and clone the repo.
- Run
xcodegen generateto create the.xcodeproj. - Create a feature branch:
git checkout -b feature/gradle-cache-cleaning - Follow existing patterns in
ScannerService/CleanerService. - Never add network calls, analytics SDKs, or telemetry of any kind.
- Large & Old Files must never be auto-selected for deletion.
- Open a PR against
main.
See CONTRIBUTING.md for full guidelines.
Troubleshooting
| Problem | Solution |
|---|---|
xcodegen: command not found |
brew install xcodegen |
| App blocked by Gatekeeper | The release build is notarized; if building from source, run xattr -cr PureMac.app |
| Purgeable scan returns 0 bytes | No local Time Machine snapshots exist — this is normal if TM is off |
| Xcode paths not found | Xcode has not been used yet or DerivedData was already cleared |
tmutil requires password |
Purgeable purge may prompt for admin credentials — this is expected macOS behavior |
| Scheduled cleaning not triggering | Ensure the app is running (it is not a background daemon); check Settings → Schedule |
Safety Guarantees
- Never deletes system-critical files or application bundles.
- Only removes caches, logs, temp files, and user-selected items.
- Large & Old Files require explicit user selection before deletion.
- Purgeable operations target only APFS Time Machine snapshots — not free space.
- All filesystem operations use
FileManager.removeItem(at:)— norm -rfshell calls for regular cleaning.