diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index de85333..e12bc66 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -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" @@ -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" @@ -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" @@ -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 diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift index 00bcb8e..1537594 100644 --- a/PureMac/Services/ScanEngine.swift +++ b/PureMac/Services/ScanEngine.swift @@ -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 @@ -19,6 +28,8 @@ actor ScanEngine { return scanSystemJunk() case .userCache: return scanUserCache() + case .aiApps: + return scanAIApps() case .mailAttachments: return scanMailAttachments() case .trashBins: @@ -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 @@ -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] = [] @@ -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 ) } diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index 891817e..a53e90e 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -27,6 +27,7 @@ final class AppState: ObservableObject { @Published var currentScanCategory: String = "" @Published var showCleanConfirmation = false @Published var lastCleanedDate: Date? + @Published var selectedCleanupItems: Set = [] @Published var deselectedItems: Set = [] @Published var hasFullDiskAccess: Bool = true @Published var fdaBannerDismissed: Bool = false @@ -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) + } } } @@ -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() { @@ -340,7 +373,7 @@ final class AppState: ObservableObject { categoryResults = [:] totalJunkSize = 0 scanProgress = 0 - deselectedItems.removeAll() + clearSelectionState() Task { let categories = CleaningCategory.scannable @@ -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 @@ -405,6 +438,7 @@ final class AppState: ObservableObject { categoryResults = [:] totalJunkSize = 0 + clearSelectionState() scanState = .cleaned loadDiskInfo() @@ -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 @@ -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) diff --git a/PureMac/en.lproj/Localizable.strings b/PureMac/en.lproj/Localizable.strings index 37ee79e..5c12d00 100644 --- a/PureMac/en.lproj/Localizable.strings +++ b/PureMac/en.lproj/Localizable.strings @@ -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"; diff --git a/PureMac/es.lproj/Localizable.strings b/PureMac/es.lproj/Localizable.strings index 03b7a56..c272221 100644 --- a/PureMac/es.lproj/Localizable.strings +++ b/PureMac/es.lproj/Localizable.strings @@ -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"; diff --git a/PureMac/ja.lproj/Localizable.strings b/PureMac/ja.lproj/Localizable.strings index 7bf567c..237c04a 100644 --- a/PureMac/ja.lproj/Localizable.strings +++ b/PureMac/ja.lproj/Localizable.strings @@ -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年以上前のファイル"; diff --git a/PureMac/zh-Hans.lproj/Localizable.strings b/PureMac/zh-Hans.lproj/Localizable.strings index c9c030b..4c7a6eb 100644 --- a/PureMac/zh-Hans.lproj/Localizable.strings +++ b/PureMac/zh-Hans.lproj/Localizable.strings @@ -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 或一年以上的文件"; diff --git a/PureMac/zh-Hant.lproj/Localizable.strings b/PureMac/zh-Hant.lproj/Localizable.strings index d83665d..750ce17 100644 --- a/PureMac/zh-Hant.lproj/Localizable.strings +++ b/PureMac/zh-Hant.lproj/Localizable.strings @@ -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 或超過一年未使用的檔案"; diff --git a/README.md b/README.md index eb9089e..13f4257 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/README.es.md b/docs/README.es.md index 64bf643..55effc1 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -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 @@ -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 diff --git a/docs/README.ja.md b/docs/README.ja.md index 954a5c1..b743526 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app - **スマートスキャン** — すべてのカテゴリをワンクリックでスキャン - **システムジャンク** — システムキャッシュ、ログ、一時ファイル - **ユーザーキャッシュ** — すべてのアプリキャッシュを動的に検出(ハードコーディングされたリストなし) +- **AIアプリ** — Ollama と LM Studio のログ、キャッシュ、任意のローカル履歴クリーンアップ - **メール添付ファイル** — ダウンロード済みのメール添付 - **ゴミ箱** — すべてのゴミ箱を空に - **大容量・古いファイル** — 100 MB を超える、または 1 年以上経過したファイル @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app - シンボリックリンク攻撃の防止 — 削除前にパスを解決・検証 - システムアプリ保護 — Apple 製アプリはアンインストール不可 - 大容量・古いファイルは自動選択されません +- AI のプロンプト履歴と会話履歴は確認用に表示されますが、自動選択されません - `os.log` による構造化ログ(Console.app で閲覧可能) ## スクリーンショット diff --git a/docs/README.zh-Hans.md b/docs/README.zh-Hans.md index 109fde1..c821e05 100644 --- a/docs/README.zh-Hans.md +++ b/docs/README.zh-Hans.md @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app - **智能扫描** — 一键扫描所有类别 - **系统垃圾** — 系统缓存、日志和临时文件 - **用户缓存** — 动态发现所有应用缓存(无需硬编码应用列表) +- **AI 应用** — Ollama 和 LM Studio 日志、缓存,以及可选的本地历史记录清理 - **邮件附件** — 已下载的邮件附件 - **废纸篓** — 清空所有废纸篓 - **大文件与旧文件** — 超过 100 MB 或超过 1 年的文件 @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app - 防御符号链接攻击 — 删除前解析并验证路径 - 系统应用保护 — Apple 应用无法被卸载 - 大文件与旧文件永远不会被自动选中 +- AI 提示和对话历史记录会显示供用户检查,但永远不会自动选中 - 通过 `os.log` 进行结构化日志记录(可在“控制台”应用中查看) ## 截图 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index b3f0962..df651d1 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -82,6 +82,7 @@ open build/Build/Products/Release/PureMac.app - **智慧掃描** — 一鍵掃描所有分類 - **系統垃圾** — 系統快取、記錄與暫存檔案 - **使用者快取** — 動態發現所有應用程式快取(不需寫死清單) +- **AI 應用程式** — Ollama 與 LM Studio 日誌、快取,以及可選的本機歷史記錄清理 - **郵件附件** — 已下載的郵件附件 - **垃圾桶** — 清空所有垃圾桶 - **大型與舊檔案** — 超過 100 MB 或超過 1 年的檔案 @@ -102,6 +103,7 @@ open build/Build/Products/Release/PureMac.app - 符號連結攻擊防護 — 刪除前先解析並驗證路徑 - 系統應用程式保護 — Apple 應用程式無法被解除安裝 - 大型與舊檔案永遠不會被自動勾選 +- AI 提示與對話歷史記錄會顯示供使用者檢視,但永遠不會自動勾選 - 透過 `os.log` 進行結構化記錄(可在「主控台」App 中檢視) ## 螢幕截圖