Skip to content
Open
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: 4 additions & 0 deletions PureMac/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable {
case smartScan = "Smart Scan"
case systemJunk = "System Junk"
case userCache = "User Cache"
case aiApps = "AI Apps"
case mailAttachments = "Mail Files"
case trashBins = "Trash Bins"
case largeFiles = "Large & Old Files"
Expand All @@ -22,6 +23,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable {
case .smartScan: return "sparkles"
case .systemJunk: return "gearshape.fill"
case .userCache: return "internaldrive.fill"
case .aiApps: return "cpu.fill"
case .mailAttachments: return "envelope.fill"
case .trashBins: return "trash.fill"
case .largeFiles: return "doc.fill"
Expand All @@ -38,6 +40,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable {
case .smartScan: return "Scan everything at once"
case .systemJunk: return "System caches, logs, and temporary files"
case .userCache: return "Application caches and browser data"
case .aiApps: return "Local AI app logs, caches, and optional history"
case .mailAttachments: return "Downloaded mail attachments"
case .trashBins: return "Files in your Trash"
case .largeFiles: return "Files over 100 MB or older than 1 year"
Expand All @@ -54,6 +57,7 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable {
case .smartScan: return .accentColor
case .systemJunk: return .purple
case .userCache: return .blue
case .aiApps: return .teal
case .mailAttachments: return .orange
case .trashBins: return .red
case .largeFiles: return .yellow
Expand Down
84 changes: 78 additions & 6 deletions PureMac/Services/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ actor ScanEngine {
private struct CleanupTarget {
let name: String
let path: String
let isSelected: Bool
let minimumSize: Int64

init(name: String, path: String, isSelected: Bool = true, minimumSize: Int64 = 1024) {
self.name = name
self.path = path
self.isSelected = isSelected
self.minimumSize = minimumSize
}
}

// MARK: - Public API
Expand All @@ -19,6 +28,8 @@ actor ScanEngine {
return scanSystemJunk()
case .userCache:
return scanUserCache()
case .aiApps:
return scanAIApps()
case .mailAttachments:
return scanMailAttachments()
case .trashBins:
Expand Down Expand Up @@ -96,9 +107,11 @@ actor ScanEngine {

private func scanUserCache() -> CategoryResult {
var items: [CleanableItem] = []
// Only exclude Homebrew since it has its own dedicated scan category
// Exclude cache roots claimed by dedicated categories to avoid double-counting.
let excludedRootPaths = Set([
"\(home)/Library/Caches/Homebrew",
"\(home)/Library/Caches/com.electron.ollama",
"\(home)/Library/Caches/ollama",
].map(normalizePath))

// Dynamically enumerate ~/Library/Caches/ so every subdirectory is visible
Expand Down Expand Up @@ -136,6 +149,59 @@ actor ScanEngine {
return CategoryResult(category: .userCache, items: uniqueItems, totalSize: totalSize)
}

private func scanAIApps() -> CategoryResult {
let targets = [
CleanupTarget(
name: "Ollama Logs",
path: "\(home)/.ollama/logs"
),
CleanupTarget(
name: "Ollama Cache",
path: "\(home)/Library/Caches/ollama"
),
CleanupTarget(
name: "Ollama Electron Cache",
path: "\(home)/Library/Caches/com.electron.ollama"
),
CleanupTarget(
name: "Ollama WebKit Data",
path: "\(home)/Library/WebKit/com.electron.ollama"
),
CleanupTarget(
name: "Ollama Saved State",
path: "\(home)/Library/Saved Application State/com.electron.ollama.savedState"
),
CleanupTarget(
name: "Ollama CLI Prompt History (Optional)",
path: "\(home)/.ollama/history",
isSelected: false,
minimumSize: 0
),
CleanupTarget(
name: "LM Studio Server Logs",
path: "\(home)/.lmstudio/server-logs"
),
CleanupTarget(
name: "LM Studio Conversations (Optional)",
path: "\(home)/.lmstudio/conversations",
isSelected: false,
minimumSize: 0
),
]

let items = deduplicatedItems(targets.compactMap { target in
makeCleanupItem(
name: target.name,
path: target.path,
category: .aiApps,
isSelected: target.isSelected,
minimumSize: target.minimumSize
)
})
let totalSize = items.reduce(0) { $0 + $1.size }
return CategoryResult(category: .aiApps, items: items.sorted { $0.size > $1.size }, totalSize: totalSize)
}

private func scanMailAttachments() -> CategoryResult {
var items: [CleanableItem] = []

Expand Down Expand Up @@ -660,34 +726,40 @@ actor ScanEngine {
return items
}

private func makeCleanupItem(name: String, path: String, category: CleaningCategory) -> CleanableItem? {
private func makeCleanupItem(
name: String,
path: String,
category: CleaningCategory,
isSelected: Bool = true,
minimumSize: Int64 = 1024
) -> CleanableItem? {
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
fileManager.isReadableFile(atPath: path) else { return nil }

if isDirectory.boolValue {
let size = directorySize(path: path)
guard size > 1024 else { return nil }
guard size > minimumSize else { return nil }
return CleanableItem(
name: name,
path: path,
size: size,
category: category,
isSelected: true,
isSelected: isSelected,
lastModified: fileModDate(path: path)
)
}

guard let attrs = try? fileManager.attributesOfItem(atPath: path),
let size = attrs[.size] as? Int64,
size > 1024 else { return nil }
size > minimumSize else { return nil }

return CleanableItem(
name: name,
path: path,
size: size,
category: category,
isSelected: true,
isSelected: isSelected,
lastModified: attrs[.modificationDate] as? Date
)
}
Expand Down
53 changes: 45 additions & 8 deletions PureMac/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class AppState: ObservableObject {
@Published var currentScanCategory: String = ""
@Published var showCleanConfirmation = false
@Published var lastCleanedDate: Date?
@Published var selectedCleanupItems: Set<UUID> = []
@Published var deselectedItems: Set<UUID> = []
@Published var hasFullDiskAccess: Bool = true
@Published var fdaBannerDismissed: Bool = false
Expand Down Expand Up @@ -237,28 +238,47 @@ final class AppState: ObservableObject {
// MARK: - Selection

func isItemSelected(_ item: CleanableItem) -> Bool {
!deselectedItems.contains(item.id)
if item.isSelected {
return !deselectedItems.contains(item.id)
}
return selectedCleanupItems.contains(item.id)
}

func toggleItem(_ item: CleanableItem) {
if deselectedItems.contains(item.id) {
deselectedItems.remove(item.id)
if isItemSelected(item) {
if item.isSelected {
deselectedItems.insert(item.id)
} else {
selectedCleanupItems.remove(item.id)
}
} else {
deselectedItems.insert(item.id)
if item.isSelected {
deselectedItems.remove(item.id)
} else {
selectedCleanupItems.insert(item.id)
}
}
}

func selectAllInCategory(_ category: CleaningCategory) {
guard let result = categoryResults[category] else { return }
for item in result.items {
deselectedItems.remove(item.id)
if item.isSelected {
deselectedItems.remove(item.id)
} else {
selectedCleanupItems.insert(item.id)
}
}
}

func deselectAllInCategory(_ category: CleaningCategory) {
guard let result = categoryResults[category] else { return }
for item in result.items {
deselectedItems.insert(item.id)
if item.isSelected {
deselectedItems.insert(item.id)
} else {
selectedCleanupItems.remove(item.id)
}
}
}

Expand Down Expand Up @@ -307,6 +327,19 @@ final class AppState: ObservableObject {
)
}

private func clearSelectionState() {
selectedCleanupItems.removeAll()
deselectedItems.removeAll()
}

private func clearSelectionState(for category: CleaningCategory) {
guard let result = categoryResults[category] else { return }
for item in result.items {
selectedCleanupItems.remove(item.id)
deselectedItems.remove(item.id)
}
}

// MARK: - Full Disk Access

func checkFullDiskAccess() {
Expand Down Expand Up @@ -340,7 +373,7 @@ final class AppState: ObservableObject {
categoryResults = [:]
totalJunkSize = 0
scanProgress = 0
deselectedItems.removeAll()
clearSelectionState()

Task {
let categories = CleaningCategory.scannable
Expand Down Expand Up @@ -371,7 +404,7 @@ final class AppState: ObservableObject {

Task {
scanProgress = 0.5
deselectedItems.removeAll()
clearSelectionState(for: category)
let result = await scanEngine.scanCategory(category)
categoryResults[category] = result

Expand Down Expand Up @@ -405,6 +438,7 @@ final class AppState: ObservableObject {

categoryResults = [:]
totalJunkSize = 0
clearSelectionState()
scanState = .cleaned
loadDiskInfo()

Expand Down Expand Up @@ -434,6 +468,7 @@ final class AppState: ObservableObject {
totalFreedSpace = cleanResult.freedSpace
lastCleanedDate = Date()

clearSelectionState(for: category)
categoryResults.removeValue(forKey: category)
totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize }
scanState = .cleaned
Expand Down Expand Up @@ -470,6 +505,8 @@ final class AppState: ObservableObject {
private func runScheduledScan() async {
let categories = scheduler.config.categoriesToScan
var totalFound: Int64 = 0
clearSelectionState()
categoryResults = [:]

for category in categories {
let result = await scanEngine.scanCategory(category)
Expand Down
1 change: 1 addition & 0 deletions PureMac/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"System caches, logs, and temporary files" = "System caches, logs, and temporary files";
"Application caches and browser data" = "Application caches and browser data";
"Logs, caches, and temporary files from local AI apps" = "Logs, caches, and temporary files from local AI apps";
"Local AI app logs, caches, and optional history" = "Local AI app logs, caches, and optional history";
"Downloaded mail attachments" = "Downloaded mail attachments";
"Files in your Trash" = "Files in your Trash";
"Files over 100 MB or older than 1 year" = "Files over 100 MB or older than 1 year";
Expand Down
1 change: 1 addition & 0 deletions PureMac/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"System caches, logs, and temporary files" = "Cachés del sistema, registros y archivos temporales";
"Application caches and browser data" = "Cachés de aplicaciones y datos del navegador";
"Logs, caches, and temporary files from local AI apps" = "Registros, cachés y archivos temporales de apps de IA locales";
"Local AI app logs, caches, and optional history" = "Registros, cachés e historial opcional de apps de IA locales";
"Downloaded mail attachments" = "Adjuntos de correo descargados";
"Files in your Trash" = "Archivos en tu Papelera";
"Files over 100 MB or older than 1 year" = "Archivos de más de 100 MB o con más de 1 año";
Expand Down
1 change: 1 addition & 0 deletions PureMac/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"System caches, logs, and temporary files" = "システムキャッシュ、ログ、一時ファイル";
"Application caches and browser data" = "アプリキャッシュとブラウザデータ";
"Logs, caches, and temporary files from local AI apps" = "ローカルAIアプリのログ、キャッシュ、一時ファイル";
"Local AI app logs, caches, and optional history" = "ローカルAIアプリのログ、キャッシュ、任意の履歴";
"Downloaded mail attachments" = "ダウンロードしたメール添付ファイル";
"Files in your Trash" = "ゴミ箱内のファイル";
"Files over 100 MB or older than 1 year" = "100 MB超または1年以上前のファイル";
Expand Down
1 change: 1 addition & 0 deletions PureMac/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"System caches, logs, and temporary files" = "系统缓存、日志和临时文件";
"Application caches and browser data" = "应用程序缓存和浏览器数据";
"Logs, caches, and temporary files from local AI apps" = "本地 AI 应用的日志、缓存和临时文件";
"Local AI app logs, caches, and optional history" = "本地 AI 应用的日志、缓存和可选历史记录";
"Downloaded mail attachments" = "下载的邮件附件";
"Files in your Trash" = "废纸篓中的文件";
"Files over 100 MB or older than 1 year" = "超过 100 MB 或一年以上的文件";
Expand Down
1 change: 1 addition & 0 deletions PureMac/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"System caches, logs, and temporary files" = "系統快取、記錄檔及暫存檔案";
"Application caches and browser data" = "應用程式快取與瀏覽器資料";
"Logs, caches, and temporary files from local AI apps" = "來自本機 AI 應用程式的日誌、快取和暫存檔案";
"Local AI app logs, caches, and optional history" = "本機 AI 應用程式的日誌、快取與可選歷史記錄";
"Downloaded mail attachments" = "已下載的郵件附件";
"Files in your Trash" = "垃圾桶中的檔案";
"Files over 100 MB or older than 1 year" = "超過 100 MB 或超過一年未使用的檔案";
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app
- **Smart Scan** - one-click scan across all categories
- **System Junk** - system caches, logs, and temporary files
- **User Cache** - dynamically discovers all app caches (no hardcoded app list)
- **AI Apps** - Ollama and LM Studio logs, caches, and opt-in local history cleanup
- **Mail Attachments** - downloaded mail attachments
- **Trash Bins** - empty all Trash
- **Large & Old Files** - files over 100 MB or older than 1 year
Expand All @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app
- Symlink attack prevention - resolves and validates paths before deletion
- System app protection - Apple apps cannot be uninstalled
- Large & Old Files are never auto-selected
- AI prompt and conversation history is visible for review but never selected automatically
- Structured logging via `os.log` (visible in Console.app)

## Screenshots
Expand Down
2 changes: 2 additions & 0 deletions docs/README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app
- **Análisis inteligente** — análisis de un clic en todas las categorías
- **Basura del sistema** — cachés del sistema, registros y archivos temporales
- **Caché de usuario** — descubre dinámicamente todos los cachés de apps (sin lista predefinida)
- **Apps de IA** — registros, cachés y limpieza opcional del historial local de Ollama y LM Studio
- **Adjuntos de correo** — adjuntos de correo descargados
- **Papeleras** — vacía todas las papeleras
- **Archivos grandes y antiguos** — archivos de más de 100 MB o con más de 1 año
Expand All @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app
- Prevención de ataques por enlaces simbólicos — resuelve y valida rutas antes de eliminar
- Protección de apps del sistema — las apps de Apple no se pueden desinstalar
- Los archivos grandes y antiguos nunca se seleccionan automáticamente
- El historial de prompts y conversaciones de IA se muestra para revisión, pero nunca se selecciona automáticamente
- Registro estructurado con `os.log` (visible en Consola.app)

## Capturas
Expand Down
2 changes: 2 additions & 0 deletions docs/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app
- **スマートスキャン** — すべてのカテゴリをワンクリックでスキャン
- **システムジャンク** — システムキャッシュ、ログ、一時ファイル
- **ユーザーキャッシュ** — すべてのアプリキャッシュを動的に検出(ハードコーディングされたリストなし)
- **AIアプリ** — Ollama と LM Studio のログ、キャッシュ、任意のローカル履歴クリーンアップ
- **メール添付ファイル** — ダウンロード済みのメール添付
- **ゴミ箱** — すべてのゴミ箱を空に
- **大容量・古いファイル** — 100 MB を超える、または 1 年以上経過したファイル
Expand All @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app
- シンボリックリンク攻撃の防止 — 削除前にパスを解決・検証
- システムアプリ保護 — Apple 製アプリはアンインストール不可
- 大容量・古いファイルは自動選択されません
- AI のプロンプト履歴と会話履歴は確認用に表示されますが、自動選択されません
- `os.log` による構造化ログ(Console.app で閲覧可能)

## スクリーンショット
Expand Down
Loading
Loading