Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
014501a
chore: bump project version to 2.0.0
Jul 14, 2026
cdf2703
feat: implement DiskAnalyzer feature with scanner and view models, an…
Jul 14, 2026
75f794c
feat: redesign dashboard with glass-morphism and radar disk usage chart
Jul 14, 2026
978b7d1
feat: add interactive update checker and integrate glass UI effect in…
Jul 16, 2026
52f3d6a
feat: add localized message and prompt to disk analyzer folder select…
Jul 16, 2026
d14b4ad
feat: implement AIExplanationService to provide context for cleanup i…
Jul 16, 2026
40c0c23
refactor: optimize disk scanning to perform flattened file lookup and…
Jul 16, 2026
6008186
fix: update disk analyzer category label from Dev-Builds to Apps acro…
Jul 16, 2026
0f9aa77
refactor: implement VSplitView for log panel, adjust cleanup timeouts…
Jul 16, 2026
e495455
feat: add show-in-finder button to cleanup items for direct path navi…
Jul 16, 2026
72bc0fe
feat: implement global glass overlay system with toast notifications,…
Jul 16, 2026
73fbab8
feat: enhance UI with glass card effects, improve cleanup category ma…
Jul 17, 2026
fa60029
feat: refactor sidebar navigation and enhance UI with new section lay…
Jul 17, 2026
45e8b7a
feat: enhance uninstaller functionality with improved candidate colle…
Jul 17, 2026
9d53353
feat: enhance cleanup functionality with new embedded and generated p…
Jul 17, 2026
75aba63
refactor: update archive icon to doc.zipper and adjust footer spacer …
Jul 17, 2026
a926ec4
feat: enhance uninstallation process with improved safety checks, int…
Jul 17, 2026
d1fa9d3
feat: add localized group title mapping for cleanup categories, enhan…
Jul 17, 2026
6f80099
fix: prevent adding cleaned items with zero freed bytes to the result…
Jul 17, 2026
8e80585
feat: improve directory size calculations by prioritizing allocated s…
Jul 17, 2026
7634249
docs: update README documentation and refresh project screenshots to …
Jul 17, 2026
9bd141c
chore: ignore .cursor directory in git repository
Jul 17, 2026
d7104ab
Delete .cursor directory
AlexTkDev Jul 17, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Package.resolved
# ============================================================
# IDE
# ============================================================

.cursor/
.idea/
.vscode/
.agent/
Expand Down Expand Up @@ -119,3 +119,4 @@ xcuserdata/
*.xcuserstate
opencode.json
.agents
problematic_apps/
65 changes: 65 additions & 0 deletions MacOSCleaner/App/MacOSCleanerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@ import OSLog

private let crashLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "input.MacOSCleaner", category: "Crash")

class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

@main
struct MacOSCleanerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Note: AppIntents/linkd errors (com.apple.linkd.autoShortcut connection failures)
// are expected system noise on some macOS versions. They cannot be fixed in app code
// as they originate from the system's AppIntents framework initialization.
Expand All @@ -17,6 +24,7 @@ struct MacOSCleanerApp: App {
private let permissionsManager = PermissionsManager()
private let updateChecker = UpdateChecker()
@State private var availableUpdate: String? = nil
@State private var isCheckingForUpdates = false

init() {
Self.installCrashHandlers()
Expand Down Expand Up @@ -72,6 +80,33 @@ struct MacOSCleanerApp: App {
Button("about_title".localized) {
openWindow(id: "about")
}
Button(isCheckingForUpdates ? "update.checking".localized : "update.check".localized) {
isCheckingForUpdates = true
Task { @MainActor in
let result = await updateChecker.checkForUpdate()
availableUpdate = result
isCheckingForUpdates = false

let alert = NSAlert()
alert.messageText = "update.check".localized
if let result {
alert.informativeText = String(format: "update.available".localized, result)
alert.addButton(withTitle: "update.download".localized)
alert.addButton(withTitle: "cancel".localized)
let response = alert.runModal()
if response == .alertFirstButtonReturn {
NSWorkspace.shared.open(UpdateChecker.releasesURL)
}
} else {
let accessory = NSHostingView(rootView: UpToDateAlertView())
accessory.frame = NSRect(x: 0, y: 0, width: 300, height: 70)
alert.accessoryView = accessory
alert.addButton(withTitle: "close".localized)
alert.runModal()
}
}
}
.disabled(isCheckingForUpdates)
}
}

Expand All @@ -88,3 +123,33 @@ struct MacOSCleanerApp: App {
.defaultPosition(.center)
}
}

struct UpToDateAlertView: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("update.up_to_date_message".localized)
.font(.system(size: 13))
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)

VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("update.releases_label".localized)
.font(.system(size: 11))
.foregroundColor(.secondary)
Link("https://github.com/AlexTkDev/MacOSCleaner/releases", destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/releases")!)
.font(.system(size: 11))
}

HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("update.website_label".localized)
.font(.system(size: 11))
.foregroundColor(.secondary)
Link("https://alextkdev.github.io/MacOSCleaner/", destination: URL(string: "https://alextkdev.github.io/MacOSCleaner/")!)
.font(.system(size: 11))
}
}
}
.frame(width: 300, height: 70, alignment: .leading)
}
}
80 changes: 60 additions & 20 deletions MacOSCleaner/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,59 @@ import SwiftUI

struct RootView: View {
@State private var selectedItem: NavigationItem? = .dashboard
/// Keep the sidebar visible; hiding `.windowToolbar` on Dashboard used to
/// collapse NavigationSplitView to detail-only on macOS 26+.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
let cleanupViewModel: CleanupViewModel
let journal: TransactionJournal
let appSettings: AppSettings
@Bindable var permissionsManager: PermissionsManager
@Binding var availableUpdate: String?

var body: some View {
NavigationSplitView {
List(NavigationItem.allCases, selection: $selectedItem) { item in
NavigationLink(value: item) {
Label(item.localizedTitle, systemImage: item.systemImage)
.padding(.vertical, 2)
ZStack {
NavigationSplitView(columnVisibility: $columnVisibility) {
List(selection: $selectedItem) {
ForEach(SidebarSection.all) { section in
Section {
ForEach(section.items) { item in
Label(item.localizedTitle, systemImage: item.systemImage)
.tag(item)
}
} header: {
if let titleKey = section.titleKey {
Text(titleKey.localized)
}
}
}
}
.listStyle(.sidebar)
.navigationTitle("app_title".localized)
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
} detail: {
Group {
if let selectedItem {
contentView(for: selectedItem)
.modifier(ScreenNavigationTitleModifier(item: selectedItem))
} else {
Text("sidebar_select_item".localized)
.foregroundColor(.secondary)
}
}
.scrollEdgeEffectStyle(.hard, for: .top)
.frame(minWidth: 800, minHeight: 600)
}
.listStyle(.sidebar)
.navigationTitle("app_title".localized)
.navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280)
} detail: {
if let selectedItem {
contentView(for: selectedItem)
.frame(minWidth: 800, minHeight: 600)
} else {
Text("sidebar_select_item".localized)
.foregroundColor(.secondary)
.frame(minWidth: 800, minHeight: 600)
}
.navigationSplitViewStyle(.balanced)

GlassOverlayView(manager: GlassOverlayManager.shared)
}

.sheet(isPresented: $permissionsManager.showGuidance) {
PermissionsView(permissionsManager: permissionsManager)
}
.onAppear {
appSettings.applyTheme()
}
.task {
permissionsManager.refresh()
permissionsManager.showGuidanceIfNeeded()
}
Expand All @@ -47,10 +67,12 @@ struct RootView: View {
DashboardView(journal: journal)
case .cleanup:
CleanupView(viewModel: cleanupViewModel)
case .diskSpace:
DiskAnalyzerView(settings: appSettings)
case .processes:
ProcessesView()
ProcessesView(settings: appSettings)
case .startupServices:
StartupServicesView()
StartupServicesView(settings: appSettings)
case .uninstaller:
UninstallerView(settings: appSettings, navigateToCleanup: { selectedItem = .cleanup })
case .settings:
Expand All @@ -68,6 +90,23 @@ struct RootView: View {
}
}

private struct ScreenNavigationTitleModifier: ViewModifier {
let item: NavigationItem

func body(content: Content) -> some View {
if item == .dashboard {
// Keep the window toolbar (sidebar toggle lives there). Only drop the
// detail title so Dashboard stays chrome-light without collapsing the split.
content
.navigationTitle("")
.toolbar(removing: .title)
} else {
content
.navigationTitle(item.localizedTitle)
}
}
}

#Preview {
let journal = TransactionJournal()
let settings = AppSettings()
Expand All @@ -84,3 +123,4 @@ struct RootView: View {
availableUpdate: .constant(nil)
)
}

136 changes: 136 additions & 0 deletions MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,142 @@ extension CleanupCategory {
"category.\(rawValue)".localized
}

/// Maps scanner/engine group labels to the localized category title used in the preview UI.
public static func localizedGroupTitle(for scannerLabel: String) -> String {
for category in CleanupCategory.allCases where category.previewLabels.contains(scannerLabel) {
return category.localizedTitle
}
return scannerLabel
}

public var previewLabels: Set<String> {
var labels: Set<String> = [localizedTitle]

switch self {
case .appCaches:
labels.insert("App caches")
labels.insert("Selected app caches")
case .packageManagers:
labels.insert("Package managers")
labels.formUnion(["Homebrew cache", "npm cache", "yarn cache", "pnpm store", "CocoaPods cache"])
case .gradleMaven:
labels.insert("Gradle + Maven")
labels.insert("Gradle caches + wrapper + daemon")
case .flutterDart:
labels.insert("Flutter / Dart")
labels.insert("Dart/Flutter package caches")
case .xcode:
labels.insert("Xcode")
labels.insert("Xcode cleanup")
case .iosSimulators:
labels.insert("iOS Simulators")
labels.insert("Simulator caches")
case .androidCaches:
labels.insert("Android caches")
labels.insert("Android build caches")
case .androidSDK:
labels.insert("Android SDK")
labels.insert("Android SDK cleanup")
case .ideCaches:
labels.insert("IDE / Electron caches")
case .browserCaches:
labels.insert("Browser caches")
case .messagingMedia:
labels.insert("Messaging / media")
labels.insert("Messaging / media caches")
case .docker:
labels.insert("Docker")
case .languageCaches:
labels.insert("Language caches")
case .userLogs:
labels.insert("User logs")
case .systemCaches:
labels.insert("System caches")
case .appContainers:
labels.insert("App containers")
labels.insert("App container caches")
case .dotfileCaches:
labels.insert("Dotfile caches")
case .scatteredJunk:
labels.insert("Scattered junk")
case .orphanedRemnants:
labels.insert("Orphaned remnants")
case .orphanedFiles:
labels.insert("Orphaned files")
case .largeFiles:
labels.insert("Large files")
case .dynamicCacheDiscovery:
labels.formUnion(["Dynamic cache discovery", "Auto-cleanable", "Review manually"])
case .timeMachineSnapshots:
labels.insert("Time Machine Snapshots")
case .iosBackups:
labels.insert("iOS Backups")
case .mailDownloads:
labels.insert("Mail Downloads")
case .savedAppState:
labels.insert("Saved Application State")
case .crashReporter:
labels.insert("Crash Reporter")
case .assetsV2:
labels.insert("AssetsV2 / iWork Templates")
case .cloudKitCache:
labels.insert("CloudKit Cache")
case .swiftPMCache:
labels.insert("Swift Package Manager Cache")
case .carthageCache:
labels.insert("Carthage Cache")
case .steamCache:
labels.insert("Steam Cache")
case .teamsCache:
labels.insert("Microsoft Teams Cache")
case .adobeCaches:
labels.insert("Adobe Caches")
case .chromeExtraCaches:
labels.insert("Chrome Extra Caches")
case .ideOldVersions:
labels.insert("Old IDE Versions")
labels.insert("Old IDE versions")
case .launchAgents:
labels.insert("Launch Agents")
case .launchDaemons:
labels.insert("Launch Daemons")
case .privilegedHelpers:
labels.insert("Privileged Helper Tools")
case .pkgReceipts:
labels.insert("Package Receipts")
case .internetPlugins:
labels.insert("Internet Plugins")
case .sharedFileLists:
labels.insert("Shared File Lists")
case .cloudDocs:
labels.insert("Cloud Docs")
case .photosCache:
labels.insert("Photos Cache")
case .voiceMemos:
labels.insert("Voice Memos")
case .garageBandLogic:
labels.insert("GarageBand / Logic Pro")
case .iMovieFinalCut:
labels.insert("iMovie / Final Cut")
case .garminFitbit:
labels.insert("Garmin / Fitbit")
case .oldBackups:
labels.insert("Old Backups")
case .dnsFlush:
labels.insert("DNS Cache")
case .fontCache:
labels.insert("Font Cache")
case .sleepImage:
labels.insert("Sleep Image")
case .duplicateFiles:
labels.insert("Duplicate Files")
case .unusedApps:
labels.insert("Unused Apps")
}

return labels
}

public static func fromFixturePathType(_ pathType: CleanupPathType) -> [CleanupCategory] {
switch pathType {
case .caches:
Expand Down
Loading
Loading