diff --git a/CleanMac.xcodeproj/project.pbxproj b/CleanMac.xcodeproj/project.pbxproj index db400c2..cc6817f 100644 --- a/CleanMac.xcodeproj/project.pbxproj +++ b/CleanMac.xcodeproj/project.pbxproj @@ -261,7 +261,7 @@ CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -275,7 +275,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.codex.cleanmac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -296,7 +296,7 @@ CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -310,7 +310,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.codex.cleanmac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/CleanMac/CleanMacApp.swift b/CleanMac/CleanMacApp.swift index fae3a5f..659304d 100644 --- a/CleanMac/CleanMacApp.swift +++ b/CleanMac/CleanMacApp.swift @@ -7,6 +7,7 @@ struct CleanMacApp: App { @Environment(\.openWindow) private var openWindow @AppStorage(CleanMacAppearance.storageKey) private var appearanceMode = CleanMacAppearance.defaultCode @AppStorage(CleanMacLanguage.storageKey) private var languageCode = CleanMacLanguage.defaultCode + @AppStorage(CleanMacPreferenceKeys.onboardingCompleted) private var onboardingCompleted = false private var language: CleanMacLanguage { CleanMacLanguage(rawValue: languageCode) ?? .current @@ -18,9 +19,18 @@ struct CleanMacApp: App { var body: some Scene { WindowGroup("CleanMac", id: "main") { - MainWindowView() - .environment(\.locale, language.locale) - .preferredColorScheme(appearance.colorScheme) + Group { + if onboardingCompleted { + MainWindowView() + .preferredColorScheme(appearance.colorScheme) + } else { + OnboardingView { + onboardingCompleted = true + } + .preferredColorScheme(nil) + } + } + .environment(\.locale, language.locale) } .defaultSize(width: 980, height: 720) .windowResizability(.contentMinSize) @@ -44,7 +54,7 @@ struct CleanMacApp: App { MenuBarExtra("CleanMac", image: "MenuBarIcon") { StatusMenuView() .environment(\.locale, language.locale) - .preferredColorScheme(appearance.colorScheme) + .environment(\.colorScheme, appearance.colorScheme) } .menuBarExtraStyle(.window) } @@ -54,9 +64,9 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate { private let autoScanScheduler = CleanMacAutoScanScheduler() func applicationDidFinishLaunching(_ notification: Notification) { + MainWindowController.prepareForInitialPresentation(isBackgroundLaunch: !NSApp.isActive) NSApp.setActivationPolicy(.regular) configureDockIcon() - NSApp.activate(ignoringOtherApps: true) CleanMacNotificationService.configure() requestNotificationAuthorizationIfUseful() autoScanScheduler.start() @@ -66,6 +76,15 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate { false } + func applicationWillBecomeActive(_ notification: Notification) { + MainWindowController.handleReopen() + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + MainWindowController.handleReopen() + return true + } + func applicationWillTerminate(_ notification: Notification) { autoScanScheduler.stop() } diff --git a/CleanMac/Models/CleanMacModels.swift b/CleanMac/Models/CleanMacModels.swift index 27e78b7..2f3faac 100644 --- a/CleanMac/Models/CleanMacModels.swift +++ b/CleanMac/Models/CleanMacModels.swift @@ -5,8 +5,9 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case dashboard case scan case results + case diskAnalysis + case duplicates case applications - case permissions case settings var id: String { rawValue } @@ -16,8 +17,9 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .dashboard: L.t("section.dashboard") case .scan: L.t("section.scan") case .results: L.t("section.results") + case .diskAnalysis: L.t("section.diskAnalysis") + case .duplicates: L.t("section.duplicates") case .applications: L.t("section.applications") - case .permissions: L.t("section.permissions") case .settings: L.t("section.settings") } } @@ -27,8 +29,9 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .dashboard: "gauge.with.dots.needle.67percent" case .scan: "magnifyingglass" case .results: "checklist" + case .diskAnalysis: "chart.pie.fill" + case .duplicates: "square.on.square" case .applications: "app.badge.checkmark" - case .permissions: "lock.shield" case .settings: "gearshape" } } @@ -112,6 +115,9 @@ extension CleanupScanReason { case .browserCache: L.t("results.reason.browserCache.title") case .nodePackageCache: L.t("results.reason.nodePackageCache.title") case .swiftPackageCache: L.t("results.reason.swiftPackageCache.title") + case .developerPackageCache: L.t("results.reason.developerPackageCache.title") + case .developerIDECache: L.t("results.reason.developerIDECache.title") + case .developerAITemporaryFile: L.t("results.reason.developerAITemporaryFile.title") case .staleLog: L.t("results.reason.staleLog.title") case .rotatedLog: L.t("results.reason.rotatedLog.title") case .staleTemporary: L.t("results.reason.staleTemporary.title") @@ -120,6 +126,10 @@ extension CleanupScanReason { case .oldDownload: L.t("results.reason.oldDownload.title") case .installerArchive: L.t("results.reason.installerArchive.title") case .xcodeBuildData: L.t("results.reason.xcodeBuildData.title") + case .xcodeDeviceSupport: L.t("results.reason.xcodeDeviceSupport.title") + case .xcodePreviewData: L.t("results.reason.xcodePreviewData.title") + case .staleSimulatorData: L.t("results.reason.staleSimulatorData.title") + case .xcodeArchive: L.t("results.reason.xcodeArchive.title") } } @@ -129,6 +139,9 @@ extension CleanupScanReason { case .browserCache: L.t("results.reason.browserCache.detail") case .nodePackageCache: L.t("results.reason.nodePackageCache.detail") case .swiftPackageCache: L.t("results.reason.swiftPackageCache.detail") + case .developerPackageCache: L.t("results.reason.developerPackageCache.detail") + case .developerIDECache: L.t("results.reason.developerIDECache.detail") + case .developerAITemporaryFile: L.t("results.reason.developerAITemporaryFile.detail") case .staleLog: L.t("results.reason.staleLog.detail") case .rotatedLog: L.t("results.reason.rotatedLog.detail") case .staleTemporary: L.t("results.reason.staleTemporary.detail") @@ -137,6 +150,10 @@ extension CleanupScanReason { case .oldDownload: L.t("results.reason.oldDownload.detail") case .installerArchive: L.t("results.reason.installerArchive.detail") case .xcodeBuildData: L.t("results.reason.xcodeBuildData.detail") + case .xcodeDeviceSupport: L.t("results.reason.xcodeDeviceSupport.detail") + case .xcodePreviewData: L.t("results.reason.xcodePreviewData.detail") + case .staleSimulatorData: L.t("results.reason.staleSimulatorData.detail") + case .xcodeArchive: L.t("results.reason.xcodeArchive.detail") } } @@ -146,6 +163,9 @@ extension CleanupScanReason { case .browserCache: "safari" case .nodePackageCache: "curlybraces.square" case .swiftPackageCache: "swift" + case .developerPackageCache: "shippingbox.and.arrow.backward" + case .developerIDECache: "curlybraces.square" + case .developerAITemporaryFile: "sparkles.rectangle.stack" case .staleLog: "doc.text.magnifyingglass" case .rotatedLog: "arrow.triangle.2.circlepath" case .staleTemporary: "clock.arrow.circlepath" @@ -154,6 +174,10 @@ extension CleanupScanReason { case .oldDownload: "calendar.badge.clock" case .installerArchive: "opticaldiscdrive" case .xcodeBuildData: "hammer" + case .xcodeDeviceSupport: "iphone.gen3" + case .xcodePreviewData: "rectangle.on.rectangle.angled" + case .staleSimulatorData: "iphone.gen3.slash" + case .xcodeArchive: "archivebox" } } } @@ -311,6 +335,33 @@ enum CleanMacCatalog { risk: .safe, isDefaultSelected: true ), + CleanupArea( + category: .developerPackageCaches, + title: L.t("area.developerPackages.title"), + detail: L.t("area.developerPackages.detail"), + pathHint: "Homebrew, pip, ~/.cargo/registry, ~/.gradle/caches", + systemImage: "shippingbox.and.arrow.backward", + risk: .safe, + isDefaultSelected: true + ), + CleanupArea( + category: .developerIDECaches, + title: L.t("area.developerIDEs.title"), + detail: L.t("area.developerIDEs.detail"), + pathHint: "Cursor, Visual Studio Code — Cache only", + systemImage: "curlybraces.square", + risk: .safe, + isDefaultSelected: true + ), + CleanupArea( + category: .developerAITemporaryFiles, + title: L.t("area.developerAI.title"), + detail: L.t("area.developerAI.detail"), + pathHint: "~/.codex/{.tmp,tmp,cache}, ~/.claude/{cache,paste-cache}", + systemImage: "sparkles.rectangle.stack", + risk: .safe, + isDefaultSelected: true + ), CleanupArea( category: .logs, title: L.t("area.logs.title"), @@ -364,6 +415,42 @@ enum CleanMacCatalog { systemImage: "hammer", risk: .review, isDefaultSelected: true + ), + CleanupArea( + category: .xcodeDeviceSupport, + title: L.t("area.xcodeDeviceSupport.title"), + detail: L.t("area.xcodeDeviceSupport.detail"), + pathHint: "~/Library/Developer/Xcode/iOS DeviceSupport", + systemImage: "iphone.gen3", + risk: .review, + isDefaultSelected: false + ), + CleanupArea( + category: .xcodePreviews, + title: L.t("area.xcodePreviews.title"), + detail: L.t("area.xcodePreviews.detail"), + pathHint: "~/Library/Developer/Xcode/UserData/Previews", + systemImage: "rectangle.on.rectangle.angled", + risk: .safe, + isDefaultSelected: true + ), + CleanupArea( + category: .xcodeSimulatorData, + title: L.t("area.xcodeSimulator.title"), + detail: L.t("area.xcodeSimulator.detail"), + pathHint: "~/Library/Developer/CoreSimulator/{Devices,Profiles/Runtimes}", + systemImage: "iphone.gen3.slash", + risk: .review, + isDefaultSelected: false + ), + CleanupArea( + category: .xcodeArchives, + title: L.t("area.xcodeArchives.title"), + detail: L.t("area.xcodeArchives.detail"), + pathHint: "~/Library/Developer/Xcode/Archives/*.xcarchive", + systemImage: "archivebox", + risk: .review, + isDefaultSelected: false ) ] } diff --git a/CleanMac/Support/CleanMacPreferences.swift b/CleanMac/Support/CleanMacPreferences.swift index 8497ddf..cd23400 100644 --- a/CleanMac/Support/CleanMacPreferences.swift +++ b/CleanMac/Support/CleanMacPreferences.swift @@ -2,6 +2,7 @@ import CleanMacCore import Foundation enum CleanMacPreferenceKeys { + static let onboardingCompleted = "CleanMac.onboardingCompleted" static let selectedAreaIDs = "CleanMac.selectedAreaIDs" static let lastScanItemCount = "CleanMac.lastScanItemCount" static let lastScanBytes = "CleanMac.lastScanBytes" diff --git a/CleanMac/Support/DiskAnalysisWorkspaceService.swift b/CleanMac/Support/DiskAnalysisWorkspaceService.swift new file mode 100644 index 0000000..c294bf2 --- /dev/null +++ b/CleanMac/Support/DiskAnalysisWorkspaceService.swift @@ -0,0 +1,27 @@ +import AppKit + +@MainActor +enum DiskAnalysisWorkspaceService { + static func chooseFolder() -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.prompt = L.t("disk.source.choose.confirm") + panel.message = L.t("disk.source.choose.message") + return panel.runModal() == .OK ? panel.url?.resolvingSymlinksInPath().standardizedFileURL : nil + } + + static func reveal(_ url: URL) async { + let revealedWithAutomation = await CleanMacAutomationService.revealInFinder(url) + if !revealedWithAutomation { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + + @discardableResult + static func open(_ url: URL) -> Bool { + NSWorkspace.shared.open(url) + } +} diff --git a/CleanMac/Support/DuplicateWorkspaceService.swift b/CleanMac/Support/DuplicateWorkspaceService.swift new file mode 100644 index 0000000..b1750f5 --- /dev/null +++ b/CleanMac/Support/DuplicateWorkspaceService.swift @@ -0,0 +1,26 @@ +import AppKit +import CleanMacCore + +@MainActor +enum DuplicateWorkspaceService { + static func chooseFolder() -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.prompt = L.t("duplicates.source.choose.confirm") + panel.message = L.t("duplicates.source.choose.message") + return panel.runModal() == .OK + ? panel.url?.resolvingSymlinksInPath().standardizedFileURL + : nil + } + + static func reveal(_ file: DuplicateFile) async { + let url = URL(fileURLWithPath: file.path) + let revealedWithAutomation = await CleanMacAutomationService.revealInFinder(url) + if !revealedWithAutomation { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } +} diff --git a/CleanMac/Support/LaunchAtLoginManager.swift b/CleanMac/Support/LaunchAtLoginManager.swift new file mode 100644 index 0000000..dea76cd --- /dev/null +++ b/CleanMac/Support/LaunchAtLoginManager.swift @@ -0,0 +1,63 @@ +import Combine +import ServiceManagement + +@MainActor +final class LaunchAtLoginManager: ObservableObject { + struct Failure: Equatable { + let enabling: Bool + let systemMessage: String + } + + static let shared = LaunchAtLoginManager() + + @Published private(set) var status: SMAppService.Status + @Published private(set) var isBusy = false + @Published private(set) var lastFailure: Failure? + + var isRequested: Bool { + status == .enabled || status == .requiresApproval + } + + private init() { + status = SMAppService.mainApp.status + } + + func refresh(clearFailure: Bool = false) { + status = SMAppService.mainApp.status + if clearFailure { + lastFailure = nil + } + } + + func setEnabled(_ enabled: Bool) async { + guard !isBusy, enabled != isRequested else { + return + } + + isBusy = true + lastFailure = nil + + let failureMessage = await Task.detached(priority: .userInitiated) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + return nil as String? + } catch { + return error.localizedDescription + } + }.value + + status = SMAppService.mainApp.status + if let failureMessage { + lastFailure = Failure(enabling: enabled, systemMessage: failureMessage) + } + isBusy = false + } + + func openSystemSettings() { + SMAppService.openSystemSettingsLoginItems() + } +} diff --git a/CleanMac/Support/MainWindowController.swift b/CleanMac/Support/MainWindowController.swift index e75b9f8..dbc8fe7 100644 --- a/CleanMac/Support/MainWindowController.swift +++ b/CleanMac/Support/MainWindowController.swift @@ -6,6 +6,25 @@ enum MainWindowController { private static let preferredInitialSize = NSSize(width: 980, height: 720) private static let minimumSize = NSSize(width: 900, height: 680) private static var fittedWindowIDs = Set() + private static var suppressInitialPresentation = false + + @MainActor + static func prepareForInitialPresentation(isBackgroundLaunch: Bool) { + suppressInitialPresentation = isBackgroundLaunch + } + + @MainActor + static func handleReopen() { + suppressInitialPresentation = false + + guard let window = NSApp.windows.first(where: { $0.identifier == identifier }) else { + return + } + + window.deminiaturize(nil) + window.alphaValue = 1 + window.makeKeyAndOrderFront(nil) + } @MainActor static func configure(_ window: NSWindow) { @@ -20,12 +39,22 @@ enum MainWindowController { fittedWindowIDs.insert(windowID) expandWindowIfNeeded(window) + + if suppressInitialPresentation { + window.alphaValue = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + finishInitialPresentation(of: window) + } + } } @MainActor static func show(openWindow: OpenWindowAction) { + suppressInitialPresentation = false + if let existingWindow = NSApp.windows.first(where: { $0.identifier == identifier }) { existingWindow.deminiaturize(nil) + existingWindow.alphaValue = 1 existingWindow.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return @@ -35,6 +64,14 @@ enum MainWindowController { NSApp.activate(ignoringOtherApps: true) } + @MainActor + private static func finishInitialPresentation(of window: NSWindow) { + if suppressInitialPresentation && !NSApp.isActive { + window.orderOut(nil) + } + window.alphaValue = 1 + } + @MainActor private static func adjustedMinimumSize(for window: NSWindow) -> NSSize { guard let visibleFrame = window.screen?.visibleFrame else { diff --git a/CleanMac/Support/StatusSystemMetrics.swift b/CleanMac/Support/StatusSystemMetrics.swift new file mode 100644 index 0000000..9cd273c --- /dev/null +++ b/CleanMac/Support/StatusSystemMetrics.swift @@ -0,0 +1,283 @@ +import Darwin +import Foundation +import IOKit.ps + +struct StatusSystemSnapshot: Equatable { + let cpuFraction: Double + let memoryFraction: Double + let memoryUsedBytes: Int64 + let disk: StatusDiskSnapshot + let battery: StatusBatterySnapshot? + let downloadBytesPerSecond: Int64 + let uploadBytesPerSecond: Int64 + let uptime: TimeInterval + + static var initial: StatusSystemSnapshot { + StatusSystemSnapshot( + cpuFraction: 0, + memoryFraction: 0, + memoryUsedBytes: 0, + disk: .current(), + battery: StatusBatterySnapshot.current(), + downloadBytesPerSecond: 0, + uploadBytesPerSecond: 0, + uptime: ProcessInfo.processInfo.systemUptime + ) + } +} + +struct StatusDiskSnapshot: Equatable { + let volumeName: String? + let totalBytes: Int64 + let freeBytes: Int64 + + var usedBytes: Int64 { + max(totalBytes - freeBytes, 0) + } + + var usedFraction: Double { + guard totalBytes > 0 else { + return 0 + } + return min(max(Double(usedBytes) / Double(totalBytes), 0), 1) + } + + static func current() -> StatusDiskSnapshot { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: homeURL.path)) ?? [:] + let resourceValues = try? homeURL.resourceValues(forKeys: [ + .volumeLocalizedNameKey, + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey + ]) + + let resourceTotalBytes = Int64(resourceValues?.volumeTotalCapacity ?? 0) + let systemTotalBytes = numberValue(attributes[.systemSize]) + let totalBytes = resourceTotalBytes > 0 ? resourceTotalBytes : systemTotalBytes + + let importantUsageBytes = resourceValues?.volumeAvailableCapacityForImportantUsage ?? 0 + let availableBytes = Int64(resourceValues?.volumeAvailableCapacity ?? 0) + let systemFreeBytes = numberValue(attributes[.systemFreeSize]) + let freeBytes = if importantUsageBytes > 0 { + importantUsageBytes + } else if availableBytes > 0 { + availableBytes + } else { + systemFreeBytes + } + + return StatusDiskSnapshot( + volumeName: resourceValues?.volumeLocalizedName, + totalBytes: totalBytes, + freeBytes: min(max(freeBytes, 0), totalBytes) + ) + } + + private static func numberValue(_ value: Any?) -> Int64 { + if let number = value as? NSNumber { + return max(number.int64Value, 0) + } + if let intValue = value as? Int { + return max(Int64(intValue), 0) + } + if let int64Value = value as? Int64 { + return max(int64Value, 0) + } + return 0 + } +} + +struct StatusBatterySnapshot: Equatable { + let fraction: Double + let isCharging: Bool + let isConnectedToPower: Bool + + static func current() -> StatusBatterySnapshot? { + guard + let powerInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sourceList = IOPSCopyPowerSourcesList(powerInfo)?.takeRetainedValue() as? [CFTypeRef] + else { + return nil + } + + for source in sourceList { + guard + let description = IOPSGetPowerSourceDescription(powerInfo, source)?.takeUnretainedValue() as? [String: Any], + let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int, + let maximumCapacity = description[kIOPSMaxCapacityKey] as? Int, + maximumCapacity > 0 + else { + continue + } + + let state = description[kIOPSPowerSourceStateKey] as? String + return StatusBatterySnapshot( + fraction: min(max(Double(currentCapacity) / Double(maximumCapacity), 0), 1), + isCharging: description[kIOPSIsChargingKey] as? Bool ?? false, + isConnectedToPower: state == kIOPSACPowerValue + ) + } + + return nil + } +} + +struct StatusSystemSampler { + private struct CPUTicks { + let busy: UInt64 + let idle: UInt64 + } + + private struct NetworkCounters { + let received: UInt64 + let sent: UInt64 + } + + private var previousCPU: CPUTicks? + private var previousNetwork: (date: Date, counters: NetworkCounters)? + + mutating func sample() -> StatusSystemSnapshot { + let now = Date() + let currentCPU = readCPUTicks() + let cpuFraction = cpuUsage(current: currentCPU, previous: previousCPU) + previousCPU = currentCPU + + let memory = readMemory() + let currentNetwork = readNetworkCounters() + let networkRates = networkRates(current: currentNetwork, at: now, previous: previousNetwork) + previousNetwork = (now, currentNetwork) + + return StatusSystemSnapshot( + cpuFraction: cpuFraction, + memoryFraction: memory.fraction, + memoryUsedBytes: memory.usedBytes, + disk: .current(), + battery: StatusBatterySnapshot.current(), + downloadBytesPerSecond: networkRates.received, + uploadBytesPerSecond: networkRates.sent, + uptime: ProcessInfo.processInfo.systemUptime + ) + } + + private func readCPUTicks() -> CPUTicks { + var load = host_cpu_load_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + + let result = withUnsafeMutablePointer(to: &load) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, reboundPointer, &count) + } + } + + guard result == KERN_SUCCESS else { + return CPUTicks(busy: 0, idle: 0) + } + + let ticks = load.cpu_ticks + return CPUTicks( + busy: UInt64(ticks.0) + UInt64(ticks.1) + UInt64(ticks.3), + idle: UInt64(ticks.2) + ) + } + + private func cpuUsage(current: CPUTicks, previous: CPUTicks?) -> Double { + guard let previous else { + return 0 + } + + let busyDelta = counterDelta(current.busy, previous.busy) + let idleDelta = counterDelta(current.idle, previous.idle) + let totalDelta = busyDelta + idleDelta + guard totalDelta > 0 else { + return 0 + } + + return min(max(Double(busyDelta) / Double(totalDelta), 0), 1) + } + + private func readMemory() -> (fraction: Double, usedBytes: Int64) { + var statistics = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + + let result = withUnsafeMutablePointer(to: &statistics) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in + host_statistics64(mach_host_self(), HOST_VM_INFO64, reboundPointer, &count) + } + } + + let totalBytes = ProcessInfo.processInfo.physicalMemory + guard result == KERN_SUCCESS, totalBytes > 0 else { + return (0, 0) + } + + var pageSize: vm_size_t = 0 + host_page_size(mach_host_self(), &pageSize) + let usedPages = UInt64(statistics.active_count) + + UInt64(statistics.wire_count) + + UInt64(statistics.compressor_page_count) + let usedBytes = min(usedPages * UInt64(pageSize), totalBytes) + + return ( + min(max(Double(usedBytes) / Double(totalBytes), 0), 1), + Int64(clamping: usedBytes) + ) + } + + private func readNetworkCounters() -> NetworkCounters { + var firstAddress: UnsafeMutablePointer? + guard getifaddrs(&firstAddress) == 0, let firstAddress else { + return NetworkCounters(received: 0, sent: 0) + } + defer { freeifaddrs(firstAddress) } + + var received: UInt64 = 0 + var sent: UInt64 = 0 + var currentAddress: UnsafeMutablePointer? = firstAddress + + while let address = currentAddress { + defer { currentAddress = address.pointee.ifa_next } + + let flags = Int32(address.pointee.ifa_flags) + guard + flags & IFF_UP != 0, + flags & IFF_LOOPBACK == 0, + let socketAddress = address.pointee.ifa_addr, + socketAddress.pointee.sa_family == UInt8(AF_LINK), + let rawData = address.pointee.ifa_data + else { + continue + } + + let interfaceData = rawData.assumingMemoryBound(to: if_data.self).pointee + received += UInt64(interfaceData.ifi_ibytes) + sent += UInt64(interfaceData.ifi_obytes) + } + + return NetworkCounters(received: received, sent: sent) + } + + private func networkRates( + current: NetworkCounters, + at date: Date, + previous: (date: Date, counters: NetworkCounters)? + ) -> (received: Int64, sent: Int64) { + guard let previous else { + return (0, 0) + } + + let interval = max(date.timeIntervalSince(previous.date), 0.001) + return ( + Int64(clamping: UInt64(Double(counterDelta(current.received, previous.counters.received)) / interval)), + Int64(clamping: UInt64(Double(counterDelta(current.sent, previous.counters.sent)) / interval)) + ) + } + + private func counterDelta(_ current: UInt64, _ previous: UInt64) -> UInt64 { + current >= previous ? current - previous : current + } +} diff --git a/CleanMac/Views/DiskAnalysisProgressIndicator.swift b/CleanMac/Views/DiskAnalysisProgressIndicator.swift new file mode 100644 index 0000000..5ffc24c --- /dev/null +++ b/CleanMac/Views/DiskAnalysisProgressIndicator.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct DiskAnalysisProgressIndicator: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 30, paused: reduceMotion)) { timeline in + let phase = reduceMotion ? 0.15 : timeline.date.timeIntervalSinceReferenceDate + let rotation = Angle.degrees(phase.truncatingRemainder(dividingBy: 2.4) / 2.4 * 360) + let pulse = reduceMotion ? 1.0 : 0.92 + (sin(phase * 3.2) + 1) * 0.04 + + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.accentColor.opacity(0.2), + Color.cyan.opacity(0.08), + .clear + ], + center: .center, + startRadius: 2, + endRadius: 30 + ) + ) + .scaleEffect(pulse) + + Circle() + .stroke(Color.accentColor.opacity(0.14), lineWidth: 5) + + Circle() + .trim(from: 0.06, to: 0.7) + .stroke( + AngularGradient( + colors: [.blue, .cyan, .purple, .blue], + center: .center + ), + style: StrokeStyle(lineWidth: 5, lineCap: .round) + ) + .rotationEffect(rotation) + .shadow(color: Color.accentColor.opacity(0.32), radius: 5) + + Circle() + .trim(from: 0.08, to: 0.38) + .stroke( + Color.white.opacity(0.8), + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .rotationEffect(.degrees(-rotation.degrees * 0.72)) + + Image(systemName: "chart.pie.fill") + .font(.system(size: 16, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + .scaleEffect(pulse) + } + } + .frame(width: 58, height: 58) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L.t("disk.scanning.title")) + } +} diff --git a/CleanMac/Views/DiskAnalysisView.swift b/CleanMac/Views/DiskAnalysisView.swift new file mode 100644 index 0000000..a35dea9 --- /dev/null +++ b/CleanMac/Views/DiskAnalysisView.swift @@ -0,0 +1,842 @@ +import CleanMacCore +import SwiftUI + +struct DiskAnalysisView: View { + @State private var source: DiskAnalysisSource = .home + @State private var customFolderURL: URL? + @State private var mode: DiskAnalysisMode = .map + @State private var threshold: LargeFileThreshold = .megabytes100 + @State private var sort: LargeFileSort = .size + @State private var report: DiskAnalysisReport? + @State private var progress: DiskAnalysisProgress? + @State private var navigationStack: [DiskAnalysisNode] = [] + @State private var selectedFileID: String? + @State private var statusMessage: String? + @State private var problemMessage: String? + @State private var isScanning = false + @State private var activeScanID: UUID? + @State private var workerTask: Task? + @State private var coordinatorTask: Task? + + private var sourceURL: URL? { + switch source { + case .wholeDisk: + URL(fileURLWithPath: "/", isDirectory: true) + case .home: + FileManager.default.homeDirectoryForCurrentUser + case .downloads: + FileManager.default.homeDirectoryForCurrentUser.appending(path: "Downloads", directoryHint: .isDirectory) + case .custom: + customFolderURL + } + } + + private var currentNode: DiskAnalysisNode? { + navigationStack.last ?? report?.root + } + + private var visibleLargeFiles: [DiskAnalysisFile] { + guard let report else { + return [] + } + + let filtered = report.largeFiles.filter { $0.sizeBytes >= threshold.bytes } + switch sort { + case .size: + return filtered.sorted { + if $0.sizeBytes == $1.sizeBytes { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return $0.sizeBytes > $1.sizeBytes + } + case .modified: + return filtered.sorted { + let leftDate = $0.modifiedAt ?? .distantPast + let rightDate = $1.modifiedAt ?? .distantPast + if leftDate == rightDate { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return leftDate > rightDate + } + case .type: + return filtered.sorted { + if $0.fileType == $1.fileType { + if $0.sizeBytes == $1.sizeBytes { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return $0.sizeBytes > $1.sizeBytes + } + return $0.fileType.title.localizedStandardCompare($1.fileType.title) == .orderedAscending + } + } + } + + private var selectedFile: DiskAnalysisFile? { + guard let selectedFileID else { + return nil + } + return report?.largeFiles.first { $0.id == selectedFileID } + } + + var body: some View { + PageContainer { + VStack(alignment: .leading, spacing: 16) { + PageHeader( + title: L.t("disk.title"), + subtitle: L.t("disk.subtitle"), + systemImage: "chart.pie.fill" + ) + + StatusBanner( + title: L.t("disk.safety.title"), + message: L.t("disk.safety.message"), + systemImage: "eye", + tint: .blue + ) + + sourceControls + + if isScanning { + scanningPanel + } + + if let statusMessage { + StatusBanner( + title: L.t("disk.status.title"), + message: statusMessage, + systemImage: "checkmark.circle", + tint: .green + ) + } + + if let problemMessage { + StatusBanner( + title: L.t("disk.problem.title"), + message: problemMessage, + systemImage: "exclamationmark.triangle", + tint: .orange + ) + } + + if let report { + analysisSummary(report) + modePicker + + switch mode { + case .map: + mapContent + case .largeFiles: + largeFilesContent + } + } else if !isScanning { + emptyState + } + } + } + .onChange(of: source) { _, newSource in + resetResults() + if newSource == .custom, customFolderURL == nil { + chooseCustomFolder() + } + } + .onChange(of: threshold) { _, _ in + keepOnlyVisibleSelection() + } + .onDisappear { + cancelScan(showMessage: false) + } + } + + private var sourceControls: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 12) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + sourcePicker + Spacer(minLength: 8) + sourceActions + } + + VStack(alignment: .leading, spacing: 12) { + sourcePicker + sourceActions + } + } + + Divider() + + HStack(spacing: 8) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + Text(sourceURL?.path ?? L.t("disk.source.custom.placeholder")) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private var sourcePicker: some View { + Picker(L.t("disk.source.title"), selection: $source) { + ForEach(DiskAnalysisSource.allCases) { source in + Text(source.title).tag(source) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 560) + } + + private var sourceActions: some View { + HStack(spacing: 10) { + if source == .custom { + Button { + chooseCustomFolder() + } label: { + Label(L.t("disk.source.choose"), systemImage: "folder.badge.plus") + } + .disabled(isScanning) + } + + if isScanning { + Button(role: .cancel) { + cancelScan(showMessage: true) + } label: { + Label(L.t("button.cancel"), systemImage: "xmark.circle") + } + } else { + Button { + startScan() + } label: { + Label(L.t("disk.scan.button"), systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(sourceURL == nil) + } + } + } + + private var scanningPanel: some View { + InfoPanel { + HStack(spacing: 14) { + DiskAnalysisProgressIndicator() + + VStack(alignment: .leading, spacing: 5) { + Text(L.t("disk.scanning.title")) + .font(.headline) + + Text(L.f( + "disk.scanning.summary", + progress?.visitedItemCount ?? 0, + CleanMacFormatters.bytes(progress?.measuredSizeBytes ?? 0), + progress?.largeFileCount ?? 0 + )) + .foregroundStyle(.secondary) + + if let currentPath = progress?.currentPath { + Text(currentPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + } + } + } + + private func analysisSummary(_ report: DiskAnalysisReport) -> some View { + InfoPanel { + ViewThatFits(in: .horizontal) { + HStack(spacing: 24) { + summaryMetric(L.t("disk.summary.measured"), CleanMacFormatters.bytes(report.root.sizeBytes), "internaldrive") + Divider().frame(height: 34) + summaryMetric(L.t("disk.summary.items"), "\(report.visitedItemCount)", "doc.on.doc") + Divider().frame(height: 34) + summaryMetric(L.t("disk.summary.largeFiles"), "\(report.largeFiles.count)", "arrow.up.right.square") + Spacer() + } + + VStack(alignment: .leading, spacing: 10) { + summaryMetric(L.t("disk.summary.measured"), CleanMacFormatters.bytes(report.root.sizeBytes), "internaldrive") + summaryMetric(L.t("disk.summary.items"), "\(report.visitedItemCount)", "doc.on.doc") + summaryMetric(L.t("disk.summary.largeFiles"), "\(report.largeFiles.count)", "arrow.up.right.square") + } + } + } + } + + private func summaryMetric(_ title: String, _ value: String, _ icon: String) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.tint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.headline) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var modePicker: some View { + Picker(L.t("disk.mode.title"), selection: $mode) { + ForEach(DiskAnalysisMode.allCases) { mode in + Label(mode.title, systemImage: mode.systemImage).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 430) + } + + @ViewBuilder + private var mapContent: some View { + if let currentNode { + InfoPanel { + VStack(alignment: .leading, spacing: 14) { + mapBreadcrumbs + + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 18) { + DiskSunburstView(node: currentNode, nodeTitle: displayName(for:), onOpenNode: openMapNode) + .frame(minWidth: 430, minHeight: 430) + + mapLegend(for: currentNode) + .frame(width: 220) + } + + VStack(alignment: .leading, spacing: 14) { + DiskSunburstView(node: currentNode, nodeTitle: displayName(for:), onOpenNode: openMapNode) + .frame(minHeight: 430) + mapLegend(for: currentNode) + } + } + + Text(L.t("disk.map.note")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var mapBreadcrumbs: some View { + HStack(spacing: 8) { + Button { + guard navigationStack.count > 1 else { return } + navigationStack.removeLast() + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + .disabled(navigationStack.count <= 1) + .help(L.t("disk.map.back")) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(Array(navigationStack.enumerated()), id: \.element.id) { index, node in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Button(displayName(for: node)) { + navigationStack = Array(navigationStack.prefix(index + 1)) + } + .buttonStyle(.plain) + .font(.subheadline.weight(index == navigationStack.count - 1 ? .semibold : .regular)) + } + } + } + + Spacer(minLength: 8) + + if let path = currentNode?.path { + Button { + Task { await DiskAnalysisWorkspaceService.reveal(URL(fileURLWithPath: path)) } + } label: { + Label(L.t("disk.action.finder"), systemImage: "folder") + } + } + } + } + + private func mapLegend(for node: DiskAnalysisNode) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(displayName(for: node)) + .font(.title3.weight(.semibold)) + .lineLimit(1) + Text(CleanMacFormatters.bytes(node.sizeBytes)) + .font(.title2.weight(.medium)) + .foregroundStyle(.secondary) + + Divider() + + ForEach(Array(node.children.prefix(9).enumerated()), id: \.element.id) { index, child in + legendRow(child, colorIndex: index) + } + + if node.children.isEmpty { + Text(L.t("disk.map.empty")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func legendRow(_ child: DiskAnalysisNode, colorIndex: Int) -> some View { + let row = HStack(spacing: 8) { + Circle() + .fill(DiskSunburstPalette.color(index: colorIndex)) + .frame(width: 9, height: 9) + Text(displayName(for: child)) + .lineLimit(1) + Spacer(minLength: 4) + Text(CleanMacFormatters.bytes(child.sizeBytes)) + .foregroundStyle(.secondary) + if child.isDirectory, !child.children.isEmpty { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .font(.caption) + .contentShape(Rectangle()) + + if child.isDirectory, !child.isAggregate, !child.children.isEmpty { + Button { + openMapNode(child) + } label: { + row + } + .buttonStyle(.plain) + .help(L.t("disk.map.clickHint")) + } else { + row + } + } + + private var largeFilesContent: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 12) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + thresholdPicker + Spacer(minLength: 8) + sortPicker + } + + VStack(alignment: .leading, spacing: 10) { + thresholdPicker + sortPicker + } + } + + HStack { + Text(L.f("disk.files.count", visibleLargeFiles.count)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + fileActions + } + + Divider() + + largeFileHeader + + if visibleLargeFiles.isEmpty { + ContentUnavailableView( + L.t("disk.files.empty.title"), + systemImage: "doc.text.magnifyingglass", + description: Text(L.t("disk.files.empty.message")) + ) + .frame(minHeight: 260) + } else { + ScrollView { + LazyVStack(spacing: 5) { + ForEach(visibleLargeFiles) { file in + largeFileRow(file) + } + } + } + .frame(minHeight: 300, maxHeight: 470) + } + } + } + } + + private var thresholdPicker: some View { + Picker(L.t("disk.files.threshold"), selection: $threshold) { + ForEach(LargeFileThreshold.allCases) { threshold in + Text(threshold.title).tag(threshold) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 390) + } + + private var sortPicker: some View { + Picker(L.t("disk.files.sort"), selection: $sort) { + ForEach(LargeFileSort.allCases) { sort in + Text(sort.title).tag(sort) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 390) + } + + private var fileActions: some View { + HStack(spacing: 8) { + Button { + guard let selectedFile else { return } + Task { await DiskAnalysisWorkspaceService.reveal(URL(fileURLWithPath: selectedFile.path)) } + } label: { + Label(L.t("disk.action.finder"), systemImage: "folder") + } + .disabled(selectedFile == nil) + + Button { + guard let selectedFile else { return } + DiskAnalysisWorkspaceService.open(URL(fileURLWithPath: selectedFile.path)) + } label: { + Label(L.t("disk.action.open"), systemImage: "arrow.up.forward.app") + } + .disabled(selectedFile == nil) + } + } + + private var largeFileHeader: some View { + HStack(spacing: 10) { + Text(L.t("disk.files.column.name")) + .frame(maxWidth: .infinity, alignment: .leading) + Text(L.t("disk.files.column.type")) + .frame(width: 90, alignment: .leading) + Text(L.t("disk.files.column.modified")) + .frame(width: 110, alignment: .leading) + Text(L.t("disk.files.column.size")) + .frame(width: 90, alignment: .trailing) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + } + + private func largeFileRow(_ file: DiskAnalysisFile) -> some View { + let isSelected = selectedFileID == file.id + + return Button { + selectedFileID = file.id + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(file.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(URL(fileURLWithPath: file.path).deletingLastPathComponent().path) + .font(.caption.monospaced()) + .foregroundStyle(isSelected ? Color.white.opacity(0.78) : Color.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(file.fileType.title) + .frame(width: 90, alignment: .leading) + Text(CleanMacFormatters.relativeDate(file.modifiedAt)) + .frame(width: 110, alignment: .leading) + Text(CleanMacFormatters.bytes(file.sizeBytes)) + .fontWeight(.semibold) + .frame(width: 90, alignment: .trailing) + } + .font(.caption) + .foregroundStyle(isSelected ? Color.white : Color.primary) + .padding(.horizontal, 10) + .frame(minHeight: 50) + .background( + isSelected ? Color.accentColor : Color.primary.opacity(0.045), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .contextMenu { + Button(L.t("disk.action.finder")) { + Task { await DiskAnalysisWorkspaceService.reveal(URL(fileURLWithPath: file.path)) } + } + Button(L.t("disk.action.open")) { + DiskAnalysisWorkspaceService.open(URL(fileURLWithPath: file.path)) + } + } + } + + private var emptyState: some View { + InfoPanel { + ContentUnavailableView( + L.t("disk.empty.title"), + systemImage: "chart.pie", + description: Text(L.t("disk.empty.message")) + ) + .frame(maxWidth: .infinity, minHeight: 280) + } + } + + private func chooseCustomFolder() { + guard let selectedURL = DiskAnalysisWorkspaceService.chooseFolder() else { + if customFolderURL == nil, source == .custom { + source = .home + } + return + } + + customFolderURL = selectedURL + source = .custom + resetResults() + } + + private func startScan() { + guard let rootURL = sourceURL, !isScanning else { + return + } + + cancelScan(showMessage: false) + let scanID = UUID() + activeScanID = scanID + isScanning = true + report = nil + progress = DiskAnalysisProgress( + phase: .preparing, + currentPath: rootURL.path, + visitedItemCount: 0, + measuredSizeBytes: 0, + largeFileCount: 0 + ) + navigationStack = [] + selectedFileID = nil + statusMessage = nil + problemMessage = nil + + let minimumLargeFileSize = LargeFileThreshold.megabytes50.bytes + let progressChannel = AsyncStream.makeStream(of: DiskAnalysisProgress.self) + let worker = Task.detached(priority: .userInitiated) { + defer { progressChannel.continuation.finish() } + return try DiskAnalyzer().scan( + root: rootURL, + options: DiskAnalysisOptions(minimumLargeFileSizeBytes: minimumLargeFileSize), + progress: { progressChannel.continuation.yield($0) }, + isCancelled: { Task.isCancelled } + ) + } + workerTask = worker + + coordinatorTask = Task { + for await event in progressChannel.stream { + guard activeScanID == scanID, !Task.isCancelled else { return } + progress = event + } + + do { + let completedReport = try await worker.value + guard activeScanID == scanID, !Task.isCancelled else { return } + + report = completedReport + navigationStack = [completedReport.root] + selectedFileID = nil + statusMessage = L.f( + "disk.status.complete", + CleanMacFormatters.bytes(completedReport.root.sizeBytes), + completedReport.visitedItemCount + ) + if !completedReport.issues.isEmpty { + problemMessage = L.f("disk.problem.issues", completedReport.issues.count) + } + finishScan(scanID: scanID) + } catch is CancellationError { + guard activeScanID == scanID else { return } + finishScan(scanID: scanID) + } catch let error as DiskAnalyzerError { + guard activeScanID == scanID else { return } + problemMessage = error.localizedDiskAnalysisMessage + finishScan(scanID: scanID) + } catch { + guard activeScanID == scanID else { return } + problemMessage = L.f("disk.problem.generic", error.localizedDescription) + finishScan(scanID: scanID) + } + } + } + + private func cancelScan(showMessage: Bool) { + guard isScanning || workerTask != nil || coordinatorTask != nil else { + return + } + + activeScanID = nil + workerTask?.cancel() + coordinatorTask?.cancel() + workerTask = nil + coordinatorTask = nil + isScanning = false + progress = nil + if showMessage { + statusMessage = L.t("disk.status.cancelled") + } + } + + private func finishScan(scanID: UUID) { + guard activeScanID == scanID else { + return + } + activeScanID = nil + workerTask = nil + coordinatorTask = nil + isScanning = false + progress = nil + } + + private func resetResults() { + cancelScan(showMessage: false) + report = nil + navigationStack = [] + selectedFileID = nil + statusMessage = nil + problemMessage = nil + } + + private func openMapNode(_ node: DiskAnalysisNode) { + guard node.isDirectory, !node.isAggregate, !node.children.isEmpty else { + return + } + navigationStack.append(node) + } + + private func keepOnlyVisibleSelection() { + guard let selectedFileID, visibleLargeFiles.contains(where: { $0.id == selectedFileID }) else { + self.selectedFileID = nil + return + } + } + + private func displayName(for node: DiskAnalysisNode) -> String { + if node.path == "/" { + return (try? URL(fileURLWithPath: "/", isDirectory: true) + .resourceValues(forKeys: [.volumeNameKey]).volumeName) + ?? L.t("disk.source.wholeDisk") + } + if node.id.hasSuffix("#files") { + return L.t("disk.map.files") + } + if node.isAggregate { + return L.t("disk.map.other") + } + return node.name + } +} + +private enum DiskAnalysisSource: String, CaseIterable, Identifiable { + case wholeDisk + case home + case downloads + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .wholeDisk: L.t("disk.source.wholeDisk") + case .home: L.t("disk.source.home") + case .downloads: L.t("disk.source.downloads") + case .custom: L.t("disk.source.custom") + } + } +} + +private enum DiskAnalysisMode: String, CaseIterable, Identifiable { + case map + case largeFiles + + var id: String { rawValue } + + var title: String { + switch self { + case .map: L.t("disk.mode.map") + case .largeFiles: L.t("disk.mode.largeFiles") + } + } + + var systemImage: String { + switch self { + case .map: "chart.pie.fill" + case .largeFiles: "list.bullet.rectangle" + } + } +} + +private enum LargeFileThreshold: Int64, CaseIterable, Identifiable { + case megabytes50 = 52_428_800 + case megabytes100 = 104_857_600 + case megabytes500 = 524_288_000 + case gigabyte1 = 1_073_741_824 + + var id: Int64 { rawValue } + var bytes: Int64 { rawValue } + + var title: String { + switch self { + case .megabytes50: L.t("disk.threshold.50") + case .megabytes100: L.t("disk.threshold.100") + case .megabytes500: L.t("disk.threshold.500") + case .gigabyte1: L.t("disk.threshold.1000") + } + } +} + +private enum LargeFileSort: String, CaseIterable, Identifiable { + case size + case modified + case type + + var id: String { rawValue } + + var title: String { + switch self { + case .size: L.t("disk.files.sort.size") + case .modified: L.t("disk.files.sort.modified") + case .type: L.t("disk.files.sort.type") + } + } +} + +private extension DiskAnalysisFileType { + var title: String { + switch self { + case .document: L.t("disk.type.document") + case .image: L.t("disk.type.image") + case .video: L.t("disk.type.video") + case .audio: L.t("disk.type.audio") + case .archive: L.t("disk.type.archive") + case .application: L.t("disk.type.application") + case .code: L.t("disk.type.code") + case .diskImage: L.t("disk.type.diskImage") + case .other: L.t("disk.type.other") + } + } +} + +private extension DiskAnalyzerError { + var localizedDiskAnalysisMessage: String { + switch self { + case .rootUnavailable: L.t("disk.problem.rootUnavailable") + case .rootIsNotDirectory: L.t("disk.problem.notDirectory") + case .enumerationFailed: L.t("disk.problem.enumerationFailed") + } + } +} diff --git a/CleanMac/Views/DiskSunburstView.swift b/CleanMac/Views/DiskSunburstView.swift new file mode 100644 index 0000000..53c5ddd --- /dev/null +++ b/CleanMac/Views/DiskSunburstView.swift @@ -0,0 +1,352 @@ +import CleanMacCore +import SwiftUI + +struct DiskSunburstView: View { + let node: DiskAnalysisNode + let nodeTitle: (DiskAnalysisNode) -> String + let onOpenNode: (DiskAnalysisNode) -> Void + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var hoveredSegmentID: String? + @State private var hoverLocation: CGPoint? + + var body: some View { + GeometryReader { geometry in + let layout = DiskSunburstLayout(node: node, size: geometry.size) + let hoveredSegment = layout.segments.first { $0.id == hoveredSegmentID } + let centerAnchor = UnitPoint( + x: layout.center.x / max(geometry.size.width, 1), + y: layout.center.y / max(geometry.size.height, 1) + ) + + ZStack { + Canvas { context, canvasSize in + for segment in layout.segments { + let path = DiskSunburstSegmentShape(segment: segment, center: layout.center) + .path(in: CGRect(origin: .zero, size: canvasSize)) + context.fill( + path, + with: .color( + DiskSunburstPalette.color(index: segment.branchIndex) + .opacity(max(0.48, 0.92 - Double(segment.depth) * 0.08)) + ) + ) + context.stroke(path, with: .color(.black.opacity(0.24)), lineWidth: 0.7) + } + } + + if let hoveredSegment { + let shape = DiskSunburstSegmentShape(segment: hoveredSegment, center: layout.center) + shape + .fill( + DiskSunburstPalette.color(index: hoveredSegment.branchIndex) + .opacity(0.98) + ) + .overlay { + shape.stroke(Color.white.opacity(0.9), lineWidth: 1.4) + } + .scaleEffect(1.045, anchor: centerAnchor) + .shadow( + color: DiskSunburstPalette.color(index: hoveredSegment.branchIndex).opacity(0.42), + radius: 10 + ) + .transition( + .opacity.combined(with: .scale(scale: 0.97, anchor: centerAnchor)) + ) + .zIndex(2) + } + + Circle() + .fill(.regularMaterial) + .overlay { + Circle() + .strokeBorder(.separator.opacity(0.8), lineWidth: 1) + } + .frame(width: layout.innerRadius * 1.72, height: layout.innerRadius * 1.72) + .position(layout.center) + .zIndex(3) + + VStack(spacing: 3) { + Text(nodeTitle(node)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(CleanMacFormatters.bytes(node.sizeBytes)) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .frame(width: layout.innerRadius * 1.45) + .position(layout.center) + .zIndex(4) + + if let hoveredSegment, let hoverLocation { + VStack(alignment: .leading, spacing: 3) { + Text(nodeTitle(hoveredSegment.node)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(gigabytesText(hoveredSegment.node.sizeBytes)) + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder( + DiskSunburstPalette.color(index: hoveredSegment.branchIndex).opacity(0.55) + ) + } + .shadow(color: .black.opacity(0.18), radius: 9, y: 4) + .position(tooltipPosition(for: hoverLocation, in: geometry.size)) + .transition(.opacity.combined(with: .scale(scale: 0.94))) + .allowsHitTesting(false) + .zIndex(5) + } + } + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let location): + let segment = layout.segment(at: location) + hoverLocation = location + if hoveredSegmentID != segment?.id { + withAnimation(reduceMotion ? nil : .spring(response: 0.24, dampingFraction: 0.76)) { + hoveredSegmentID = segment?.id + } + } + case .ended: + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.14)) { + hoveredSegmentID = nil + hoverLocation = nil + } + } + } + .gesture( + SpatialTapGesture() + .onEnded { value in + guard let selected = layout.segment(at: value.location) else { + return + } + onOpenNode(selected.node) + } + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L.f( + "disk.map.accessibility", + nodeTitle(node), + CleanMacFormatters.bytes(node.sizeBytes) + )) + } + } + + private func gigabytesText(_ bytes: Int64) -> String { + let gigabytes = Double(max(0, bytes)) / 1_073_741_824 + return L.f("disk.map.tooltip.size", gigabytes) + } + + private func tooltipPosition(for location: CGPoint, in size: CGSize) -> CGPoint { + let preferredX = location.x + 82 + let preferredY = location.y - 42 + return CGPoint( + x: min(max(preferredX, 86), max(86, size.width - 86)), + y: min(max(preferredY, 34), max(34, size.height - 34)) + ) + } +} + +private struct DiskSunburstSegmentShape: Shape { + let segment: DiskSunburstSegment + let center: CGPoint + + func path(in rect: CGRect) -> Path { + var path = Path() + let innerStart = point(radius: segment.innerRadius, angle: segment.startAngle) + let innerEnd = point(radius: segment.innerRadius, angle: segment.endAngle) + + path.move(to: innerStart) + path.addArc( + center: center, + radius: segment.outerRadius, + startAngle: .radians(segment.startAngle), + endAngle: .radians(segment.endAngle), + clockwise: false + ) + path.addLine(to: innerEnd) + path.addArc( + center: center, + radius: segment.innerRadius, + startAngle: .radians(segment.endAngle), + endAngle: .radians(segment.startAngle), + clockwise: true + ) + path.closeSubpath() + return path + } + + private func point(radius: CGFloat, angle: Double) -> CGPoint { + CGPoint( + x: center.x + cos(angle) * radius, + y: center.y + sin(angle) * radius + ) + } +} + +enum DiskSunburstPalette { + private static let colors: [Color] = [ + Color(red: 0.18, green: 0.72, blue: 0.98), + Color(red: 0.72, green: 0.94, blue: 0.18), + Color(red: 0.96, green: 0.33, blue: 0.55), + Color(red: 0.75, green: 0.25, blue: 0.98), + Color(red: 1.00, green: 0.72, blue: 0.25), + Color(red: 0.24, green: 0.90, blue: 0.60), + Color(red: 0.98, green: 0.48, blue: 0.24), + Color(red: 0.38, green: 0.47, blue: 0.98), + Color(red: 0.96, green: 0.90, blue: 0.25), + Color(red: 0.20, green: 0.86, blue: 0.86) + ] + + static func color(index: Int) -> Color { + colors[index % colors.count] + } +} + +private struct DiskSunburstLayout { + let center: CGPoint + let innerRadius: CGFloat + let segments: [DiskSunburstSegment] + + private let startAngle = Double.pi * 0.75 + private let endAngle = Double.pi * 2.25 + + init(node: DiskAnalysisNode, size: CGSize) { + let radius = max(80, min(size.width, size.height) * 0.44) + self.center = CGPoint(x: size.width * 0.5, y: size.height * 0.56) + self.innerRadius = max(34, radius * 0.2) + + let maximumDepth = max(1, min(5, Self.maximumDepth(in: node))) + let ringWidth = (radius - innerRadius) / CGFloat(maximumDepth) + var builtSegments: [DiskSunburstSegment] = [] + + Self.appendChildren( + of: node, + startAngle: startAngle, + endAngle: endAngle, + depth: 0, + branchIndex: nil, + maximumDepth: maximumDepth, + innerRadius: innerRadius, + ringWidth: ringWidth, + segments: &builtSegments + ) + + self.segments = builtSegments + } + + func segment(at location: CGPoint) -> DiskSunburstSegment? { + let deltaX = location.x - center.x + let deltaY = location.y - center.y + let radius = hypot(deltaX, deltaY) + var angle = atan2(deltaY, deltaX) + if angle < 0 { + angle += Double.pi * 2 + } + while angle < startAngle { + angle += Double.pi * 2 + } + + return segments.reversed().first { segment in + radius >= segment.innerRadius + && radius <= segment.outerRadius + && angle >= segment.startAngle + && angle <= segment.endAngle + } + } + + private static func appendChildren( + of node: DiskAnalysisNode, + startAngle: Double, + endAngle: Double, + depth: Int, + branchIndex: Int?, + maximumDepth: Int, + innerRadius: CGFloat, + ringWidth: CGFloat, + segments: inout [DiskSunburstSegment] + ) { + guard depth < maximumDepth, !node.children.isEmpty else { + return + } + + let visibleChildren = node.children.filter { $0.sizeBytes > 0 } + let totalSize = visibleChildren.reduce(Int64(0)) { $0 + $1.sizeBytes } + guard totalSize > 0 else { + return + } + + var cursor = startAngle + let availableAngle = endAngle - startAngle + + for (index, child) in visibleChildren.enumerated() { + let fraction = Double(child.sizeBytes) / Double(totalSize) + let rawEnd = cursor + availableAngle * fraction + let gap = min(0.012, max(0, (rawEnd - cursor) * 0.08)) + let segmentStart = cursor + gap / 2 + let segmentEnd = rawEnd - gap / 2 + let resolvedBranchIndex = branchIndex ?? index + + if segmentEnd - segmentStart > 0.002 { + segments.append(DiskSunburstSegment( + node: child, + depth: depth, + branchIndex: resolvedBranchIndex, + startAngle: segmentStart, + endAngle: segmentEnd, + innerRadius: innerRadius + CGFloat(depth) * ringWidth, + outerRadius: innerRadius + CGFloat(depth + 1) * ringWidth + )) + + appendChildren( + of: child, + startAngle: cursor, + endAngle: rawEnd, + depth: depth + 1, + branchIndex: resolvedBranchIndex, + maximumDepth: maximumDepth, + innerRadius: innerRadius, + ringWidth: ringWidth, + segments: &segments + ) + } + + cursor = rawEnd + } + } + + private static func maximumDepth(in node: DiskAnalysisNode) -> Int { + guard !node.children.isEmpty else { + return 1 + } + + var deepestChild = 0 + for child in node.children { + deepestChild = max(deepestChild, maximumDepth(in: child)) + } + return 1 + deepestChild + } +} + +private struct DiskSunburstSegment { + let node: DiskAnalysisNode + let depth: Int + let branchIndex: Int + let startAngle: Double + let endAngle: Double + let innerRadius: CGFloat + let outerRadius: CGFloat + + var id: String { + "\(node.id)#\(depth)#\(startAngle)" + } +} diff --git a/CleanMac/Views/DuplicateFinderView.swift b/CleanMac/Views/DuplicateFinderView.swift new file mode 100644 index 0000000..e45e09a --- /dev/null +++ b/CleanMac/Views/DuplicateFinderView.swift @@ -0,0 +1,786 @@ +import CleanMacCore +import SwiftUI + +struct DuplicateFinderView: View { + @State private var source: DuplicateSearchSource = .downloads + @State private var customFolderURL: URL? + @State private var includeLargeFiles = false + @State private var scanReport: DuplicateScanReport? + @State private var groups: [DuplicateGroup] = [] + @State private var progress: DuplicateScanProgress? + @State private var selectedCopyIDs: Set = [] + @State private var expandedGroupIDs: Set = [] + @State private var statusMessage: String? + @State private var problemMessage: String? + @State private var isScanning = false + @State private var isCleaning = false + @State private var showingCleanupConfirmation = false + @State private var activeScanID: UUID? + @State private var workerTask: Task? + @State private var coordinatorTask: Task? + + private var sourceURL: URL? { + switch source { + case .home: + FileManager.default.homeDirectoryForCurrentUser + case .downloads: + FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Downloads", directoryHint: .isDirectory) + case .custom: + customFolderURL + } + } + + private var selectedCopies: [DuplicateFile] { + groups.flatMap(\.copies).filter { selectedCopyIDs.contains($0.id) } + } + + private var selectedBytes: Int64 { + selectedCopies.reduce(0) { total, file in + let result = total.addingReportingOverflow(file.sizeBytes) + return result.overflow ? Int64.max : result.partialValue + } + } + + var body: some View { + PageContainer { + VStack(alignment: .leading, spacing: 16) { + PageHeader( + title: L.t("duplicates.title"), + subtitle: L.t("duplicates.subtitle"), + systemImage: "square.on.square" + ) + + StatusBanner( + title: L.t("duplicates.safety.title"), + message: L.t("duplicates.safety.message"), + systemImage: "lock.shield", + tint: .green + ) + + sourceControls + + if isScanning { + scanningPanel + } + + if let statusMessage { + StatusBanner( + title: L.t("duplicates.status.title"), + message: statusMessage, + systemImage: "checkmark.circle", + tint: .green + ) + } + + if let problemMessage { + StatusBanner( + title: L.t("duplicates.problem.title"), + message: problemMessage, + systemImage: "exclamationmark.triangle", + tint: .orange + ) + } + + if let scanReport { + summaryPanel(scanReport) + deferredLargePanel(scanReport) + + if !groups.isEmpty { + selectionPanel + duplicateGroups + } else if !isScanning { + emptyResults + } + } else if !isScanning { + initialState + } + } + } + .onChange(of: source) { _, newSource in + resetResults() + if newSource == .custom, customFolderURL == nil { + chooseCustomFolder() + } + } + .onChange(of: includeLargeFiles) { _, _ in + resetResults() + } + .onDisappear { + cancelScan(showMessage: false) + } + .alert( + L.t("duplicates.cleanup.confirm.title"), + isPresented: $showingCleanupConfirmation + ) { + Button(L.t("button.cancel"), role: .cancel) {} + Button(L.t("duplicates.cleanup.confirm.action"), role: .destructive) { + cleanSelectedCopies() + } + } message: { + Text(L.f( + "duplicates.cleanup.confirm.message", + selectedCopies.count, + CleanMacFormatters.bytes(selectedBytes) + )) + } + } + + private var sourceControls: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 12) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + sourcePicker + Spacer(minLength: 8) + sourceActions + } + + VStack(alignment: .leading, spacing: 12) { + sourcePicker + sourceActions + } + } + + Divider() + + Toggle(isOn: $includeLargeFiles) { + VStack(alignment: .leading, spacing: 3) { + Text(L.t("duplicates.slowMode.title")) + .font(.subheadline.weight(.semibold)) + Text(L.t("duplicates.slowMode.detail")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + .disabled(isScanning || isCleaning) + + HStack(spacing: 8) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + Text(sourceURL?.path ?? L.t("duplicates.source.custom.placeholder")) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private var sourcePicker: some View { + Picker(L.t("duplicates.source.title"), selection: $source) { + ForEach(DuplicateSearchSource.allCases) { source in + Text(source.title).tag(source) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 480) + .disabled(isScanning || isCleaning) + } + + private var sourceActions: some View { + HStack(spacing: 10) { + if source == .custom { + Button { + chooseCustomFolder() + } label: { + Label(L.t("duplicates.source.choose"), systemImage: "folder.badge.plus") + } + .disabled(isScanning || isCleaning) + } + + if isScanning { + Button(role: .cancel) { + cancelScan(showMessage: true) + } label: { + Label(L.t("button.cancel"), systemImage: "xmark.circle") + } + } else { + Button { + startScan() + } label: { + Label(L.t("duplicates.scan.button"), systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(sourceURL == nil || isCleaning) + } + } + } + + private var scanningPanel: some View { + InfoPanel { + HStack(spacing: 14) { + DiskAnalysisProgressIndicator() + + VStack(alignment: .leading, spacing: 5) { + Text(progress?.phase.localizedTitle ?? L.t("duplicates.phase.enumerating")) + .font(.headline) + Text(L.f( + "duplicates.scanning.summary", + progress?.discoveredFileCount ?? 0, + progress?.candidateFileCount ?? 0, + progress?.hashedFileCount ?? 0 + )) + .foregroundStyle(.secondary) + + if let currentPath = progress?.currentPath { + Text(currentPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + } + } + } + + private func summaryPanel(_ report: DuplicateScanReport) -> some View { + InfoPanel { + ViewThatFits(in: .horizontal) { + HStack(spacing: 24) { + summaryMetric(L.t("duplicates.summary.groups"), "\(groups.count)", "square.stack.3d.up") + Divider().frame(height: 34) + summaryMetric(L.t("duplicates.summary.copies"), "\(groups.reduce(0) { $0 + $1.copies.count })", "doc.on.doc") + Divider().frame(height: 34) + summaryMetric(L.t("duplicates.summary.space"), CleanMacFormatters.bytes(reclaimableBytes), "externaldrive.badge.minus") + Divider().frame(height: 34) + summaryMetric(L.t("duplicates.summary.files"), "\(report.discoveredFileCount)", "magnifyingglass") + Spacer() + } + + VStack(alignment: .leading, spacing: 10) { + summaryMetric(L.t("duplicates.summary.groups"), "\(groups.count)", "square.stack.3d.up") + summaryMetric(L.t("duplicates.summary.copies"), "\(groups.reduce(0) { $0 + $1.copies.count })", "doc.on.doc") + summaryMetric(L.t("duplicates.summary.space"), CleanMacFormatters.bytes(reclaimableBytes), "externaldrive.badge.minus") + summaryMetric(L.t("duplicates.summary.files"), "\(report.discoveredFileCount)", "magnifyingglass") + } + } + } + } + + private var reclaimableBytes: Int64 { + groups.reduce(0) { total, group in + let result = total.addingReportingOverflow(group.reclaimableBytes) + return result.overflow ? Int64.max : result.partialValue + } + } + + private func summaryMetric(_ title: String, _ value: String, _ icon: String) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.tint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.headline) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func deferredLargePanel(_ report: DuplicateScanReport) -> some View { + if !report.deferredLargeCandidates.isEmpty { + InfoPanel { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "tortoise.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 3) { + Text(L.t("duplicates.deferred.title")) + .font(.headline) + Text(L.f( + "duplicates.deferred.detail", + report.deferredLargeCandidates.count, + CleanMacFormatters.bytes(deferredLargeBytes(report)) + )) + .foregroundStyle(.secondary) + } + Spacer() + Button(L.t("duplicates.deferred.enable")) { + includeLargeFiles = true + } + } + + ForEach(Array(report.deferredLargeCandidates.prefix(5))) { file in + compactFileRow(file) + } + + if report.deferredLargeCandidates.count > 5 { + Text(L.f("duplicates.deferred.more", report.deferredLargeCandidates.count - 5)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func deferredLargeBytes(_ report: DuplicateScanReport) -> Int64 { + report.deferredLargeCandidates.reduce(0) { total, file in + let result = total.addingReportingOverflow(file.sizeBytes) + return result.overflow ? Int64.max : result.partialValue + } + } + + private func compactFileRow(_ file: DuplicateFile) -> some View { + HStack(spacing: 10) { + Image(systemName: "doc") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .lineLimit(1) + Text(file.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(CleanMacFormatters.bytes(file.sizeBytes)) + .font(.caption.weight(.semibold)) + } + } + + private var selectionPanel: some View { + InfoPanel { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + selectionSummary + Spacer() + selectionActions + } + + VStack(alignment: .leading, spacing: 12) { + selectionSummary + selectionActions + } + } + } + } + + private var selectionSummary: some View { + VStack(alignment: .leading, spacing: 3) { + Text(L.f( + "duplicates.selection.summary", + selectedCopies.count, + CleanMacFormatters.bytes(selectedBytes) + )) + .font(.headline) + Text(L.t("duplicates.selection.detail")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var selectionActions: some View { + HStack(spacing: 10) { + Button(L.t("button.clearSelection")) { + selectedCopyIDs.removeAll() + } + .disabled(selectedCopyIDs.isEmpty || isCleaning) + + Button(role: .destructive) { + showingCleanupConfirmation = true + } label: { + Label( + isCleaning ? L.t("button.cleaning") : L.t("duplicates.cleanup.button"), + systemImage: "trash" + ) + } + .buttonStyle(.borderedProminent) + .disabled(selectedCopyIDs.isEmpty || isCleaning || isScanning) + } + } + + private var duplicateGroups: some View { + VStack(spacing: 10) { + ForEach(groups) { group in + DuplicateGroupPanel( + group: group, + isExpanded: expandedGroupIDs.contains(group.id), + selectedCopyIDs: $selectedCopyIDs, + onToggleExpanded: { toggleExpanded(group.id) } + ) + } + } + } + + private var initialState: some View { + InfoPanel { + ContentUnavailableView( + L.t("duplicates.empty.initial.title"), + systemImage: "square.on.square", + description: Text(L.t("duplicates.empty.initial.message")) + ) + .frame(maxWidth: .infinity, minHeight: 260) + } + } + + private var emptyResults: some View { + InfoPanel { + ContentUnavailableView( + L.t("duplicates.empty.results.title"), + systemImage: "checkmark.circle", + description: Text(L.t("duplicates.empty.results.message")) + ) + .frame(maxWidth: .infinity, minHeight: 220) + } + } + + private func chooseCustomFolder() { + guard let selectedURL = DuplicateWorkspaceService.chooseFolder() else { + if customFolderURL == nil, source == .custom { + source = .downloads + } + return + } + customFolderURL = selectedURL + source = .custom + resetResults() + } + + private func startScan() { + guard let rootURL = sourceURL, !isScanning, !isCleaning else { + return + } + + cancelScan(showMessage: false) + let scanID = UUID() + activeScanID = scanID + isScanning = true + scanReport = nil + groups = [] + selectedCopyIDs = [] + expandedGroupIDs = [] + statusMessage = nil + problemMessage = nil + progress = DuplicateScanProgress( + phase: .enumerating, + currentPath: rootURL.path, + discoveredFileCount: 0, + candidateFileCount: 0, + hashedFileCount: 0 + ) + + let mode: DuplicateScanMode = includeLargeFiles ? .includeLargeFiles : .standard + let progressChannel = AsyncStream.makeStream(of: DuplicateScanProgress.self) + let worker = Task.detached(priority: .userInitiated) { + defer { progressChannel.continuation.finish() } + return try await DuplicateFinder().scan( + root: rootURL, + options: DuplicateScanOptions(mode: mode, maxConcurrentHashes: 2), + progress: { progressChannel.continuation.yield($0) } + ) + } + workerTask = worker + + coordinatorTask = Task { + for await event in progressChannel.stream { + guard activeScanID == scanID, !Task.isCancelled else { return } + progress = event + } + + do { + let completedReport = try await worker.value + guard activeScanID == scanID, !Task.isCancelled else { return } + + scanReport = completedReport + groups = completedReport.groups + selectedCopyIDs = [] + expandedGroupIDs = [] + statusMessage = L.f( + "duplicates.status.complete", + completedReport.groups.count, + completedReport.copyCount, + CleanMacFormatters.bytes(completedReport.reclaimableBytes) + ) + problemMessage = completedReport.issues.isEmpty + ? nil + : L.f("duplicates.problem.issues", completedReport.issues.count) + finishScan(scanID: scanID) + } catch is CancellationError { + guard activeScanID == scanID else { return } + finishScan(scanID: scanID) + } catch let error as DuplicateFinderError { + guard activeScanID == scanID else { return } + problemMessage = error.localizedDuplicateMessage + finishScan(scanID: scanID) + } catch { + guard activeScanID == scanID else { return } + problemMessage = L.f("duplicates.problem.generic", error.localizedDescription) + finishScan(scanID: scanID) + } + } + } + + private func cancelScan(showMessage: Bool) { + guard isScanning || workerTask != nil || coordinatorTask != nil else { + return + } + activeScanID = nil + workerTask?.cancel() + coordinatorTask?.cancel() + workerTask = nil + coordinatorTask = nil + isScanning = false + progress = nil + if showMessage { + statusMessage = L.t("duplicates.status.cancelled") + } + } + + private func finishScan(scanID: UUID) { + guard activeScanID == scanID else { + return + } + activeScanID = nil + workerTask = nil + coordinatorTask = nil + isScanning = false + progress = nil + } + + private func resetResults() { + cancelScan(showMessage: false) + scanReport = nil + groups = [] + selectedCopyIDs = [] + expandedGroupIDs = [] + statusMessage = nil + problemMessage = nil + } + + private func toggleExpanded(_ groupID: String) { + withAnimation(.easeInOut(duration: 0.16)) { + if expandedGroupIDs.contains(groupID) { + expandedGroupIDs.remove(groupID) + } else { + expandedGroupIDs.insert(groupID) + } + } + } + + private func cleanSelectedCopies() { + guard let rootURL = sourceURL, !selectedCopyIDs.isEmpty, !isCleaning else { + return + } + isCleaning = true + statusMessage = nil + problemMessage = nil + let groupSnapshot = groups + let selectionSnapshot = selectedCopyIDs + + Task { + let cleanupReport = await Task.detached(priority: .userInitiated) { + let plan = DuplicateCleanupPlanner(root: rootURL).plan( + groups: groupSnapshot, + selectedCopyIDs: selectionSnapshot + ) + return DuplicateCleanupExecutor().execute(plan: plan) + }.value + + let movedIDs = Set(cleanupReport.movedItems.map(\.id)) + let movedBytes = cleanupReport.movedItems.reduce(Int64(0)) { total, item in + let result = total.addingReportingOverflow(item.item.copy.sizeBytes) + return result.overflow ? Int64.max : result.partialValue + } + groups = groups.compactMap { group in + let remainingCopies = group.copies.filter { !movedIDs.contains($0.id) } + guard !remainingCopies.isEmpty else { return nil } + return DuplicateGroup( + fullHash: group.fullHash, + original: group.original, + copies: remainingCopies + ) + } + selectedCopyIDs.subtract(movedIDs) + isCleaning = false + + if !cleanupReport.movedItems.isEmpty { + statusMessage = L.f( + "duplicates.cleanup.success", + cleanupReport.movedItems.count, + CleanMacFormatters.bytes(movedBytes) + ) + } + let problemCount = cleanupReport.failedItems.count + cleanupReport.rejectedItems.count + if problemCount > 0 { + problemMessage = L.f( + "duplicates.cleanup.problems", + cleanupReport.failedItems.count, + cleanupReport.rejectedItems.count + ) + } + } + } +} + +private enum DuplicateSearchSource: String, CaseIterable, Identifiable { + case home + case downloads + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .home: L.t("duplicates.source.home") + case .downloads: L.t("duplicates.source.downloads") + case .custom: L.t("duplicates.source.custom") + } + } +} + +private extension DuplicateScanPhase { + var localizedTitle: String { + switch self { + case .enumerating: L.t("duplicates.phase.enumerating") + case .groupingBySize: L.t("duplicates.phase.grouping") + case .partialHashing: L.t("duplicates.phase.partial") + case .fullHashing: L.t("duplicates.phase.full") + case .finalizing: L.t("duplicates.phase.finalizing") + case .completed: L.t("duplicates.phase.completed") + } + } +} + +private extension DuplicateFinderError { + var localizedDuplicateMessage: String { + switch self { + case .rootIsUnavailable: L.t("duplicates.problem.unavailable") + case .rootIsNotDirectory: L.t("duplicates.problem.notDirectory") + } + } +} + +private struct DuplicateGroupPanel: View { + let group: DuplicateGroup + let isExpanded: Bool + @Binding var selectedCopyIDs: Set + let onToggleExpanded: () -> Void + + var body: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 10) { + Button(action: onToggleExpanded) { + HStack(spacing: 10) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .foregroundStyle(.secondary) + .frame(width: 16) + Image(systemName: "square.stack.3d.up") + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text(group.original.name) + .font(.headline) + .lineLimit(1) + Text(L.f( + "duplicates.group.summary", + group.copies.count + 1, + CleanMacFormatters.bytes(group.reclaimableBytes) + )) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(CleanMacFormatters.bytes(group.original.sizeBytes)) + .font(.subheadline.weight(.semibold)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + Divider() + DuplicateFileRow(file: group.original, role: .original, isSelected: .constant(false)) + + ForEach(group.copies) { copy in + DuplicateFileRow( + file: copy, + role: .copy, + isSelected: Binding( + get: { selectedCopyIDs.contains(copy.id) }, + set: { selected in + if selected { + selectedCopyIDs.insert(copy.id) + } else { + selectedCopyIDs.remove(copy.id) + } + } + ) + ) + } + } + } + } + } +} + +private struct DuplicateFileRow: View { + enum Role { + case original + case copy + } + + let file: DuplicateFile + let role: Role + @Binding var isSelected: Bool + + var body: some View { + HStack(spacing: 10) { + if role == .copy { + Toggle("", isOn: $isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + } else { + Image(systemName: "lock.fill") + .foregroundStyle(.green) + .frame(width: 16) + } + + Image(systemName: role == .original ? "doc.badge.checkmark" : "doc.on.doc") + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 7) { + Text(file.name) + .lineLimit(1) + Text(role == .original ? L.t("duplicates.role.original") : L.t("duplicates.role.copy")) + .font(.caption2.weight(.bold)) + .foregroundStyle(role == .original ? .green : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + Text(file.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Text(CleanMacFormatters.bytes(file.sizeBytes)) + .font(.caption.weight(.semibold)) + + Button { + Task { await DuplicateWorkspaceService.reveal(file) } + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help(L.t("disk.action.finder")) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } +} diff --git a/CleanMac/Views/MainWindowView.swift b/CleanMac/Views/MainWindowView.swift index 9b5a050..87e09b2 100644 --- a/CleanMac/Views/MainWindowView.swift +++ b/CleanMac/Views/MainWindowView.swift @@ -43,13 +43,15 @@ struct MainWindowView: View { .navigationTitle(selectedSection.title) .toolbar { ToolbarItemGroup { - Button { - runScan() - } label: { - Label(isAnyScanInProgress ? L.t("button.scanning") : L.t("button.scan"), systemImage: "magnifyingglass") + if selectedSection != .diskAnalysis, selectedSection != .duplicates { + Button { + runScan() + } label: { + Label(isAnyScanInProgress ? L.t("button.scanning") : L.t("button.scan"), systemImage: "magnifyingglass") + } + .accessibilityLabel(isAnyScanInProgress ? L.t("button.scanning") : L.t("button.scan")) + .disabled(isAnyScanInProgress || selectedAreaIDs.isEmpty) } - .accessibilityLabel(isAnyScanInProgress ? L.t("button.scanning") : L.t("button.scan")) - .disabled(isAnyScanInProgress || selectedAreaIDs.isEmpty) Button { selectedSectionID = CleanMacSection.settings.rawValue @@ -115,13 +117,15 @@ struct MainWindowView: View { onConfirmCleanup: cleanupSelectedItems, onRestoreHistoryItem: restoreHistoryItem, onOpenPermissions: { - selectedSectionID = CleanMacSection.permissions.rawValue + selectedSectionID = CleanMacSection.settings.rawValue } ) + case .diskAnalysis: + DiskAnalysisView() + case .duplicates: + DuplicateFinderView() case .applications: ApplicationsView() - case .permissions: - PermissionsView() case .settings: SettingsView( safeModeEnabled: $safeModeEnabled, diff --git a/CleanMac/Views/OnboardingView.swift b/CleanMac/Views/OnboardingView.swift new file mode 100644 index 0000000..96664a2 --- /dev/null +++ b/CleanMac/Views/OnboardingView.swift @@ -0,0 +1,437 @@ +import AppKit +import SwiftUI + +struct OnboardingView: View { + private enum Step: Int, CaseIterable { + case welcome + case capabilities + case fullDiskAccess + case ready + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var step: Step = .welcome + @State private var fullDiskAccess = FullDiskAccessChecker().check() + + let onComplete: () -> Void + + var body: some View { + ZStack { + onboardingBackground + + VStack(spacing: 0) { + topBar + progressTrack + + GeometryReader { proxy in + ScrollView { + page + .frame(maxWidth: 820) + .frame(minHeight: max(500, proxy.size.height - 36)) + .padding(.horizontal, 44) + .padding(.vertical, 18) + .frame(maxWidth: .infinity) + } + } + + Divider() + footer + } + } + .frame(minWidth: 900, minHeight: 680) + .background(WindowAccessor { window in + MainWindowController.configure(window) + }) + .onChange(of: step) { _, newStep in + if newStep == .fullDiskAccess { + fullDiskAccess = FullDiskAccessChecker().check() + } + } + .accessibilityElement(children: .contain) + } + + private var onboardingBackground: some View { + ZStack { + Color(nsColor: .windowBackgroundColor) + + RadialGradient( + colors: [ + Color.accentColor.opacity(0.13), + Color.accentColor.opacity(0.035), + .clear + ], + center: .top, + startRadius: 0, + endRadius: 560 + ) + + LinearGradient( + colors: [ + Color(nsColor: .controlBackgroundColor).opacity(0.34), + .clear + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .ignoresSafeArea() + } + + private var topBar: some View { + HStack { + HStack(spacing: 8) { + Image("BrandIcon") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text(L.t("onboarding.appTitle")) + .font(.headline) + } + + Spacer() + + Button { + onComplete() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 28, height: 28) + .background(.regularMaterial, in: Circle()) + .overlay { + Circle().strokeBorder(.separator.opacity(0.55)) + } + } + .buttonStyle(.plain) + .help(L.t("onboarding.skip")) + .accessibilityLabel(L.t("onboarding.skip")) + } + .padding(.horizontal, 22) + .padding(.top, 14) + } + + private var progressTrack: some View { + HStack(spacing: 0) { + ForEach(Array(Step.allCases.enumerated()), id: \.offset) { index, item in + if index > 0 { + Capsule() + .fill(index <= step.rawValue ? Color.accentColor : Color.secondary.opacity(0.22)) + .frame(width: 46, height: 3) + } + + ZStack { + Circle() + .fill(item.rawValue <= step.rawValue ? Color.accentColor : Color.secondary.opacity(0.2)) + .frame(width: 22, height: 22) + + if item.rawValue < step.rawValue { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + } else { + Text("\(item.rawValue + 1)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(item.rawValue == step.rawValue ? .white : .secondary) + } + } + } + } + .padding(.top, 8) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L.f("onboarding.progress", step.rawValue + 1, Step.allCases.count)) + } + + @ViewBuilder + private var page: some View { + Group { + switch step { + case .welcome: + welcomePage + case .capabilities: + capabilitiesPage + case .fullDiskAccess: + fullDiskAccessPage + case .ready: + readyPage + } + } + .id(step) + .transition(reduceMotion ? .opacity : .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + + private var welcomePage: some View { + VStack(spacing: 24) { + Spacer(minLength: 34) + + Image("BrandIcon") + .resizable() + .scaledToFit() + .frame(width: 132, height: 132) + .shadow(color: Color.accentColor.opacity(0.22), radius: 24, y: 10) + .accessibilityLabel(L.t("app.name")) + + VStack(spacing: 12) { + Text(L.t("onboarding.welcome.title")) + .font(.system(size: 42, weight: .bold, design: .rounded)) + + Text(L.t("onboarding.welcome.subtitle")) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 620) + .fixedSize(horizontal: false, vertical: true) + } + + onboardingBadge( + L.t("onboarding.welcome.badge"), + systemImage: "checkmark.shield.fill", + tint: .green + ) + + Spacer(minLength: 34) + } + } + + private var capabilitiesPage: some View { + VStack(spacing: 22) { + pageHeading( + title: L.t("onboarding.capabilities.title"), + subtitle: L.t("onboarding.capabilities.subtitle") + ) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], + spacing: 14 + ) { + capabilityCard("cleanup", icon: "sparkles", tint: .blue) + capabilityCard("disk", icon: "chart.pie.fill", tint: .purple) + capabilityCard("apps", icon: "app.badge.checkmark", tint: .orange) + capabilityCard("safe", icon: "checkmark.shield.fill", tint: .green) + capabilityCard("schedule", icon: "clock.badge.checkmark", tint: .cyan) + capabilityCard("local", icon: "externaldrive.fill.badge.checkmark", tint: .indigo) + } + } + .padding(.top, 24) + } + + private var fullDiskAccessPage: some View { + VStack(spacing: 18) { + heroSymbol("lock.shield.fill", tint: .blue) + + pageHeading( + title: L.t("onboarding.access.title"), + subtitle: L.t("onboarding.access.subtitle") + ) + + onboardingBadge( + fullDiskAccess.state == .granted + ? L.t("onboarding.access.granted") + : L.t("onboarding.access.limited"), + systemImage: fullDiskAccess.state == .granted ? "checkmark.circle.fill" : "exclamationmark.circle.fill", + tint: fullDiskAccess.state == .granted ? .green : .orange + ) + + VStack(spacing: 0) { + instructionRow(number: 1, text: L.t("onboarding.access.step1")) + Divider().padding(.leading, 54) + instructionRow(number: 2, text: L.t("onboarding.access.step2")) + Divider().padding(.leading, 54) + instructionRow(number: 3, text: L.t("onboarding.access.step3")) + Divider().padding(.leading, 54) + instructionRow(number: 4, text: L.t("onboarding.access.step4")) + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.separator.opacity(0.55)) + } + + Button { + openFullDiskAccessSettings() + } label: { + Label(L.t("onboarding.access.openSettings"), systemImage: "gearshape") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Text(L.t("onboarding.access.optional")) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 10) + } + + private var readyPage: some View { + VStack(spacing: 24) { + Spacer(minLength: 50) + + ZStack { + Circle() + .fill(Color.green.gradient) + .frame(width: 116, height: 116) + .shadow(color: .green.opacity(0.24), radius: 24, y: 10) + + Image(systemName: "checkmark") + .font(.system(size: 48, weight: .bold)) + .foregroundStyle(.white) + } + + VStack(spacing: 12) { + Text(L.t("onboarding.ready.title")) + .font(.system(size: 42, weight: .bold, design: .rounded)) + + Text(L.t("onboarding.ready.subtitle")) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 650) + .fixedSize(horizontal: false, vertical: true) + } + + onboardingBadge( + L.t("onboarding.ready.badge"), + systemImage: "trash.slash.fill", + tint: .blue + ) + + Spacer(minLength: 50) + } + } + + private var footer: some View { + HStack { + Button(L.t("onboarding.back")) { + move(to: max(0, step.rawValue - 1)) + } + .controlSize(.large) + .disabled(step == .welcome) + .opacity(step == .welcome ? 0 : 1) + + Spacer() + + HStack(spacing: 7) { + ForEach(Step.allCases, id: \.rawValue) { item in + Circle() + .fill(item == step ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(width: item == step ? 9 : 7, height: item == step ? 9 : 7) + } + } + .accessibilityHidden(true) + + Spacer() + + Button(step == .ready ? L.t("onboarding.getStarted") : L.t("onboarding.next")) { + if step == .ready { + onComplete() + } else { + move(to: step.rawValue + 1) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 28) + .padding(.vertical, 16) + .background(.bar) + } + + private func pageHeading(title: String, subtitle: String) -> some View { + VStack(spacing: 8) { + Text(title) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + + Text(subtitle) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 680) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func capabilityCard(_ key: String, icon: String, tint: Color) -> some View { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(tint) + .frame(width: 38, height: 38) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(L.t("onboarding.capability.\(key).title")) + .font(.headline) + Text(L.t("onboarding.capability.\(key).detail")) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 0) + } + .padding(15) + .frame(maxWidth: .infinity, minHeight: 88, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.separator.opacity(0.5)) + } + } + + private func instructionRow(number: Int, text: String) -> some View { + HStack(spacing: 14) { + Text("\(number)") + .font(.headline) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(Color.accentColor.gradient, in: Circle()) + + Text(text) + .font(.body.weight(.medium)) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 11) + } + + private func onboardingBadge(_ title: String, systemImage: String, tint: Color) -> some View { + Label(title, systemImage: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .background(tint.opacity(0.11), in: Capsule()) + } + + private func heroSymbol(_ name: String, tint: Color) -> some View { + Image(systemName: name) + .font(.system(size: 50, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(tint) + .frame(width: 92, height: 92) + .background(tint.opacity(0.11), in: Circle()) + } + + private func move(to rawValue: Int) { + guard let destination = Step(rawValue: rawValue) else { + return + } + + withAnimation(reduceMotion ? .easeOut(duration: 0.12) : .spring(response: 0.34, dampingFraction: 0.88)) { + step = destination + } + } + + private func openFullDiskAccessSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") else { + return + } + NSWorkspace.shared.open(url) + } +} diff --git a/CleanMac/Views/PermissionsView.swift b/CleanMac/Views/PermissionsView.swift index 87588c2..3d4e234 100644 --- a/CleanMac/Views/PermissionsView.swift +++ b/CleanMac/Views/PermissionsView.swift @@ -1,52 +1,51 @@ import AppKit import SwiftUI -struct PermissionsView: View { +struct PermissionsSettingsSection: View { @State private var fullDiskAccess = FullDiskAccessChecker().check() @State private var finderAutomationPermission: FinderAutomationPermission? @State private var isRequestingFinderAutomation = false var body: some View { - PageContainer { - VStack(alignment: .leading, spacing: 20) { - PageHeader( - title: L.t("permissions.title"), - subtitle: L.t("permissions.subtitle"), - systemImage: "lock.shield" - ) - - VStack(spacing: 10) { - ForEach(CleanMacCatalog.permissions( - fullDiskAccess: fullDiskAccess, - finderAutomationPermission: finderAutomationPermission - )) { permission in - if permission.id == "automation" { - PermissionRow( - permission: permission, - actionTitle: automationActionTitle, - isActionInProgress: isRequestingFinderAutomation || finderAutomationPermission == nil, - action: handleAutomationAction - ) - } else { - PermissionRow(permission: permission) - } + VStack(alignment: .leading, spacing: 12) { + Label(L.t("permissions.title"), systemImage: "lock.shield") + .font(.title3.bold()) + + Text(L.t("permissions.subtitle")) + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(spacing: 10) { + ForEach(CleanMacCatalog.permissions( + fullDiskAccess: fullDiskAccess, + finderAutomationPermission: finderAutomationPermission + )) { permission in + if permission.id == "automation" { + PermissionRow( + permission: permission, + actionTitle: automationActionTitle, + isActionInProgress: isRequestingFinderAutomation || finderAutomationPermission == nil, + action: handleAutomationAction + ) + } else { + PermissionRow(permission: permission) } } + } - HStack { - Spacer() + HStack { + Spacer() - Button { - refreshAccess() - } label: { - Label(L.t("button.refreshAccess"), systemImage: "arrow.clockwise") - } + Button { + refreshAccess() + } label: { + Label(L.t("button.refreshAccess"), systemImage: "arrow.clockwise") + } - Button { - openPrivacySettings() - } label: { - Label(L.t("button.openSystemSettings"), systemImage: "gearshape") - } + Button { + openPrivacySettings() + } label: { + Label(L.t("button.openSystemSettings"), systemImage: "gearshape") } } } diff --git a/CleanMac/Views/SettingsView.swift b/CleanMac/Views/SettingsView.swift index 796fe99..049ff79 100644 --- a/CleanMac/Views/SettingsView.swift +++ b/CleanMac/Views/SettingsView.swift @@ -1,3 +1,4 @@ +import ServiceManagement import SwiftUI struct SettingsView: View { @@ -55,6 +56,9 @@ struct SettingsView: View { systemImage: "gearshape" ) + Label(L.t("settings.general"), systemImage: "switch.2") + .font(.title3.bold()) + InfoPanel { VStack(alignment: .leading, spacing: 14) { Toggle(isOn: $safeModeEnabled) { @@ -75,6 +79,10 @@ struct SettingsView: View { Divider() + LaunchAtLoginSettingsRow() + + Divider() + Toggle(isOn: $autoScanEnabled) { Label(L.t("settings.autoScan"), systemImage: "clock.badge.checkmark") } @@ -155,6 +163,8 @@ struct SettingsView: View { } } } + + PermissionsSettingsSection() } } } @@ -223,3 +233,141 @@ struct SettingsView: View { } } } + +private struct LaunchAtLoginSettingsRow: View { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var manager = LaunchAtLoginManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: enabledBinding) { + Label(L.t("settings.launchAtLogin"), systemImage: "person.crop.circle.badge.checkmark") + } + .disabled(manager.isBusy) + + HStack(spacing: 8) { + Label(statusTitle, systemImage: statusIcon) + .font(.caption.weight(.medium)) + .foregroundStyle(statusColor) + + if manager.isBusy { + ProgressView() + .controlSize(.small) + } + + Spacer() + + if shouldOfferSystemSettings { + Button(L.t("settings.launchAtLogin.openSystemSettings")) { + manager.openSystemSettings() + } + .controlSize(.small) + } + } + + Text(statusDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let failure = manager.lastFailure { + Label(failureText(failure), systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + .task { + manager.refresh(clearFailure: true) + } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { + return + } + manager.refresh(clearFailure: true) + } + } + + private var enabledBinding: Binding { + Binding { + manager.isRequested + } set: { enabled in + Task { + await manager.setEnabled(enabled) + } + } + } + + private var shouldOfferSystemSettings: Bool { + manager.status == .requiresApproval || manager.status == .notFound || manager.lastFailure != nil + } + + private var statusTitle: String { + switch manager.status { + case .notRegistered: + L.t("settings.launchAtLogin.status.disabled") + case .enabled: + L.t("settings.launchAtLogin.status.enabled") + case .requiresApproval: + L.t("settings.launchAtLogin.status.requiresApproval") + case .notFound: + L.t("settings.launchAtLogin.status.unavailable") + @unknown default: + L.t("settings.launchAtLogin.status.unavailable") + } + } + + private var statusDetail: String { + switch manager.status { + case .notRegistered: + L.t("settings.launchAtLogin.detail.disabled") + case .enabled: + L.t("settings.launchAtLogin.detail.enabled") + case .requiresApproval: + L.t("settings.launchAtLogin.detail.requiresApproval") + case .notFound: + L.t("settings.launchAtLogin.detail.unavailable") + @unknown default: + L.t("settings.launchAtLogin.detail.unavailable") + } + } + + private var statusIcon: String { + switch manager.status { + case .enabled: + "checkmark.circle.fill" + case .requiresApproval: + "exclamationmark.circle.fill" + case .notFound: + "xmark.circle.fill" + case .notRegistered: + "circle" + @unknown default: + "questionmark.circle" + } + } + + private var statusColor: Color { + switch manager.status { + case .enabled: + .green + case .requiresApproval: + .orange + case .notFound: + .red + case .notRegistered: + .secondary + @unknown default: + .secondary + } + } + + private func failureText(_ failure: LaunchAtLoginManager.Failure) -> String { + L.f( + failure.enabling + ? "settings.launchAtLogin.error.enable" + : "settings.launchAtLogin.error.disable", + failure.systemMessage + ) + } +} diff --git a/CleanMac/Views/StatusMenuView.swift b/CleanMac/Views/StatusMenuView.swift index 9662bb4..c876ba6 100644 --- a/CleanMac/Views/StatusMenuView.swift +++ b/CleanMac/Views/StatusMenuView.swift @@ -8,55 +8,37 @@ struct StatusMenuView: View { @AppStorage(CleanMacPreferenceKeys.lastScanBytes) private var lastScanBytes = 0.0 @AppStorage(CleanMacPreferenceKeys.lastScanTimestamp) private var lastScanTimestamp = 0.0 @AppStorage(CleanMacPreferenceKeys.lastScanSource) private var lastScanSource = CleanMacScanSource.manual.rawValue - @AppStorage(CleanMacPreferenceKeys.autoScanEnabled) private var autoScanEnabled = false @AppStorage(CleanMacPreferenceKeys.scanInProgress) private var scanInProgress = false - @State private var diskUsage = DiskUsageSnapshot.current() + @State private var sampler = StatusSystemSampler() + @State private var snapshot = StatusSystemSnapshot.initial var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { header - - DiskUsagePanel(snapshot: diskUsage) - - LastScanPanel( - itemCount: lastScanItemCount, - bytes: lastScanBytes, - timestamp: lastScanTimestamp, - source: CleanMacScanSource(rawValue: lastScanSource) ?? .manual, - isScanning: scanInProgress, - isAutoScanEnabled: autoScanEnabled - ) - - HStack(spacing: 8) { - StatusMenuActionButton( - title: L.t("menu.open.short"), - systemImage: "macwindow", - isPrimary: true - ) { - MainWindowController.show(openWindow: openWindow) - } - - StatusMenuActionButton( - title: L.t("menu.quit"), - systemImage: "power", - isPrimary: false - ) { - NSApp.terminate(nil) - } - } + metricGrid + networkStrip + systemPanel + actions } - .onAppear { - diskUsage = DiskUsageSnapshot.current() - } - .padding(18) - .frame(width: 330) + .padding(16) + .frame(width: 350) .background { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(.regularMaterial) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.08), lineWidth: 1) - } + ZStack { + (colorScheme == .dark + ? Color(red: 0.12, green: 0.13, blue: 0.14) + : Color(nsColor: .windowBackgroundColor)) + LinearGradient( + colors: [ + Color.accentColor.opacity(colorScheme == .dark ? 0.16 : 0.08), + Color.clear + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + .task { + await refreshMetrics() } } @@ -65,313 +47,321 @@ struct StatusMenuView: View { Image("BrandIcon") .resizable() .scaledToFit() - .frame(width: 34, height: 34) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .frame(width: 44, height: 44) + .shadow(color: .black.opacity(0.25), radius: 8, y: 4) + .accessibilityLabel(L.t("app.name")) - VStack(alignment: .leading, spacing: 3) { - Text("CleanMac") - .font(.system(size: 15, weight: .semibold)) + VStack(alignment: .leading, spacing: 2) { + Text(L.t("app.name")) + .font(.system(size: 21, weight: .bold, design: .rounded)) .foregroundStyle(.primary) - Text(scanInProgress ? L.t("status.scanning") : L.t("status.idle")) - .font(.system(size: 12, weight: .medium)) + Text(scanInProgress ? L.t("status.scanning") : L.t("status.metrics.subtitle")) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(scanInProgress ? Color.accentColor : Color.secondary) + .lineLimit(1) } Spacer() + + Circle() + .fill(scanInProgress ? Color.accentColor : Color.green) + .frame(width: 8, height: 8) + .shadow(color: (scanInProgress ? Color.accentColor : .green).opacity(0.55), radius: 5) + .accessibilityHidden(true) } } -} -private struct DiskUsagePanel: View { - @Environment(\.colorScheme) private var colorScheme + private var metricGrid: some View { + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], + spacing: 10 + ) { + StatusMetricCard( + title: L.t("status.metrics.cpu"), + systemImage: "cpu", + fraction: snapshot.cpuFraction, + value: percentText(snapshot.cpuFraction), + detail: L.t("status.metrics.live") + ) + + StatusMetricCard( + title: L.t("status.metrics.memory"), + systemImage: "memorychip", + fraction: snapshot.memoryFraction, + value: percentText(snapshot.memoryFraction), + detail: CleanMacFormatters.bytes(snapshot.memoryUsedBytes) + ) - let snapshot: DiskUsageSnapshot + StatusMetricCard( + title: L.t("status.metrics.disk"), + systemImage: "internaldrive", + fraction: snapshot.disk.usedFraction, + value: percentText(snapshot.disk.usedFraction), + detail: L.f("status.metrics.free", CleanMacFormatters.bytes(snapshot.disk.freeBytes)) + ) - private var usedPercent: Int { - Int((snapshot.usedFraction * 100).rounded()) + StatusMetricCard( + title: L.t("status.metrics.battery"), + systemImage: batterySystemImage, + fraction: snapshot.battery?.fraction ?? 0, + value: snapshot.battery.map { percentText($0.fraction) } ?? "—", + detail: batteryDetail + ) + } } - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 8) { - Label(snapshot.volumeName ?? L.t("status.disk.defaultName"), systemImage: "internaldrive") + private var networkStrip: some View { + HStack(spacing: 0) { + networkItem( + systemImage: "arrow.down", + value: rateText(snapshot.downloadBytesPerSecond), + tint: .accentColor + ) + + Divider() + .padding(.vertical, 9) + + networkItem( + systemImage: "arrow.up", + value: rateText(snapshot.uploadBytesPerSecond), + tint: .orange + ) + + Divider() + .padding(.vertical, 9) + + networkItem( + systemImage: "clock", + value: uptimeText(snapshot.uptime), + tint: .secondary + ) + } + .frame(height: 46) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.7)) + } + } + + private var systemPanel: some View { + VStack(spacing: 9) { + HStack(spacing: 10) { + Image(systemName: "internaldrive.fill") + .foregroundStyle(.tint) + .frame(width: 20) + + Text(snapshot.disk.volumeName ?? L.t("status.disk.defaultName")) .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) .lineLimit(1) - .truncationMode(.middle) Spacer() - Text(L.f("status.disk.percent", usedPercent)) - .font(.system(size: 12, weight: .semibold)) + Text(L.f("status.metrics.free", CleanMacFormatters.bytes(snapshot.disk.freeBytes))) + .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) + .lineLimit(1) } - HStack(alignment: .lastTextBaseline, spacing: 14) { - VStack(alignment: .leading, spacing: 1) { - Text("\(usedPercent)%") - .font(.system(size: 36, weight: .bold, design: .rounded)) - .foregroundStyle(.primary) - .monospacedDigit() + Divider() - Text(L.t("status.disk.usedLabel")) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } + HStack(spacing: 10) { + Image(systemName: scanInProgress ? "arrow.triangle.2.circlepath" : "doc.text.magnifyingglass") + .foregroundStyle(scanInProgress ? Color.accentColor : Color.secondary) + .frame(width: 20) - Spacer() + Text(scanSummary) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(scanInProgress ? Color.accentColor : Color.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) - VStack(alignment: .trailing, spacing: 1) { - Text(CleanMacFormatters.bytes(snapshot.freeBytes)) - .font(.system(size: 17, weight: .semibold, design: .rounded)) - .foregroundStyle(.primary) - .lineLimit(1) - .minimumScaleFactor(0.8) + Spacer(minLength: 6) - Text(L.t("status.disk.freeLabel")) - .font(.system(size: 12, weight: .medium)) + if lastScanTimestamp > 0, !scanInProgress { + Text(CleanMacFormatters.relativeDate(Date(timeIntervalSince1970: lastScanTimestamp))) + .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) + .lineLimit(1) } } - - DiskUsageBar(fraction: snapshot.usedFraction, tint: diskTint) - - Text(L.f( - "status.disk.usedOfTotal", - CleanMacFormatters.bytes(snapshot.usedBytes), - CleanMacFormatters.bytes(snapshot.totalBytes) - )) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) } - .padding(14) - .background( - sectionFill, - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) + .padding(13) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.primary.opacity(colorScheme == .dark ? 0.14 : 0.06), lineWidth: 1) + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.7)) } } - private var diskTint: Color { - if snapshot.usedFraction >= 0.9 { - return .red - } - if snapshot.usedFraction >= 0.75 { - return .orange + private var actions: some View { + HStack(spacing: 10) { + Button { + NSApp.terminate(nil) + } label: { + Image(systemName: "power") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 46, height: 44) + .background(Color.red, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .help(L.t("menu.quit")) + .accessibilityLabel(L.t("menu.quit")) + + Button { + MainWindowController.show(openWindow: openWindow) + } label: { + Label(L.t("menu.open"), systemImage: "leaf.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: .infinity, minHeight: 44) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) } - return .accentColor } - private var sectionFill: Color { - colorScheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.58) + private var batterySystemImage: String { + guard let battery = snapshot.battery else { + return "battery.0percent" + } + if battery.isCharging { + return "battery.100percent.bolt" + } + if battery.fraction >= 0.75 { + return "battery.100percent" + } + if battery.fraction >= 0.25 { + return "battery.50percent" + } + return "battery.25percent" } -} - -private struct LastScanPanel: View { - @Environment(\.colorScheme) private var colorScheme - - let itemCount: Int - let bytes: Double - let timestamp: Double - let source: CleanMacScanSource - let isScanning: Bool - let isAutoScanEnabled: Bool - private var hasScan: Bool { - timestamp > 0 + private var batteryDetail: String { + guard let battery = snapshot.battery else { + return L.t("status.metrics.unavailable") + } + if battery.isCharging { + return L.t("status.metrics.charging") + } + if battery.isConnectedToPower { + return L.t("status.metrics.power") + } + return L.t("status.metrics.batteryPower") } - private var nextRunDate: Date { - CleanMacScanSchedule.nextRunDate() + private var scanSummary: String { + if scanInProgress { + return L.t("status.autoScan.running") + } + guard lastScanTimestamp > 0 else { + return L.t("status.lastScan.empty") + } + return L.f( + "status.lastScan.summary", + lastScanItemCount, + CleanMacFormatters.bytes(Int64(lastScanBytes)) + ) } - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Label(L.t("status.lastScan.title"), systemImage: "doc.text.magnifyingglass") - .font(.system(size: 13, weight: .semibold)) - - Spacer() - - if isScanning { - ProgressView() - .controlSize(.small) - .scaleEffect(0.65) - } - } - - if isScanning { - Label(L.t("status.autoScan.running"), systemImage: "arrow.triangle.2.circlepath") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.tint) - } - - if hasScan { - VStack(alignment: .leading, spacing: 6) { - Text(L.f("status.lastScan.summary", itemCount, CleanMacFormatters.bytes(Int64(bytes)))) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .minimumScaleFactor(0.85) - - HStack(spacing: 6) { - Text(source == .scheduled ? L.t("status.lastScan.scheduled") : L.t("status.lastScan.manual")) - Text("·") - Text(CleanMacFormatters.relativeDate(Date(timeIntervalSince1970: timestamp))) - } - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } else { - Text(L.t("status.lastScan.empty")) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .lineSpacing(2) - .fixedSize(horizontal: false, vertical: true) - } - - if isAutoScanEnabled { - Text(L.f( - "status.autoScan.next", - CleanMacFormatters.time(nextRunDate) - )) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + private func networkItem(systemImage: String, value: String, tint: Color) -> some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .foregroundStyle(tint) + Text(value) + .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.8) - } - } - .padding(14) - .background( - sectionFill, - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.primary.opacity(colorScheme == .dark ? 0.14 : 0.06), lineWidth: 1) + .minimumScaleFactor(0.72) } + .font(.system(size: 11, weight: .bold, design: .rounded)) + .monospacedDigit() + .frame(maxWidth: .infinity) } - private var sectionFill: Color { - colorScheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.58) + private func percentText(_ fraction: Double) -> String { + "\(Int((min(max(fraction, 0), 1) * 100).rounded()))%" } -} -private struct DiskUsageBar: View { - let fraction: Double - let tint: Color + private func rateText(_ bytesPerSecond: Int64) -> String { + L.f("status.metrics.rate", CleanMacFormatters.bytes(max(bytesPerSecond, 0))) + } - var body: some View { - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.primary.opacity(0.1)) - - Capsule() - .fill( - LinearGradient( - colors: [tint, tint.opacity(0.72)], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: max(proxy.size.width * CGFloat(fraction), fraction > 0 ? 8 : 0)) + private func uptimeText(_ interval: TimeInterval) -> String { + let totalMinutes = max(Int(interval / 60), 0) + return L.f("status.metrics.uptime", totalMinutes / 60, totalMinutes % 60) + } + + private func refreshMetrics() async { + snapshot = sampler.sample() + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return } + snapshot = sampler.sample() } - .frame(height: 8) } } -private struct StatusMenuActionButton: View { +private struct StatusMetricCard: View { let title: String let systemImage: String - let isPrimary: Bool - let action: () -> Void + let fraction: Double + let value: String + let detail: String var body: some View { - Button(action: action) { + VStack(spacing: 8) { Label(title, systemImage: systemImage) - .font(.system(size: 13, weight: .semibold)) - .labelStyle(.titleAndIcon) - .foregroundStyle(isPrimary ? Color.white : Color.primary) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.primary) .lineLimit(1) - .frame(maxWidth: .infinity) - .padding(.vertical, 9) - .padding(.horizontal, 10) - .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - } - .buttonStyle(.plain) - } - - private var backgroundStyle: some ShapeStyle { - isPrimary ? Color.accentColor : Color.primary.opacity(0.08) - } -} - -private struct DiskUsageSnapshot: Equatable { - let volumeName: String? - let totalBytes: Int64 - let freeBytes: Int64 - - var usedBytes: Int64 { - max(totalBytes - freeBytes, 0) - } + .minimumScaleFactor(0.8) - var usedFraction: Double { - guard totalBytes > 0 else { - return 0 - } - return min(max(Double(usedBytes) / Double(totalBytes), 0), 1) - } + ZStack { + Circle() + .stroke(Color.primary.opacity(0.12), lineWidth: 7) - static func current() -> DiskUsageSnapshot { - let homeURL = FileManager.default.homeDirectoryForCurrentUser - let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: homeURL.path)) ?? [:] - let resourceValues = try? homeURL.resourceValues(forKeys: [ - .volumeLocalizedNameKey, - .volumeTotalCapacityKey, - .volumeAvailableCapacityKey, - .volumeAvailableCapacityForImportantUsageKey - ]) - - let resourceTotalBytes = Int64(resourceValues?.volumeTotalCapacity ?? 0) - let systemTotalBytes = numberValue(attributes[.systemSize]) - let totalBytes = resourceTotalBytes > 0 ? resourceTotalBytes : systemTotalBytes - - let importantUsageBytes = resourceValues?.volumeAvailableCapacityForImportantUsage ?? 0 - let availableBytes = Int64(resourceValues?.volumeAvailableCapacity ?? 0) - let systemFreeBytes = numberValue(attributes[.systemFreeSize]) - let freeBytes = if importantUsageBytes > 0 { - importantUsageBytes - } else if availableBytes > 0 { - availableBytes - } else { - systemFreeBytes - } + Circle() + .trim(from: 0, to: min(max(fraction, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 7, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .shadow(color: Color.accentColor.opacity(0.28), radius: 4) + .animation(.easeOut(duration: 0.45), value: fraction) - return DiskUsageSnapshot( - volumeName: resourceValues?.volumeLocalizedName, - totalBytes: totalBytes, - freeBytes: min(freeBytes, totalBytes) - ) - } + Text(value) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + .monospacedDigit() + .minimumScaleFactor(0.75) + } + .frame(width: 70, height: 70) - private static func numberValue(_ value: Any?) -> Int64 { - if let number = value as? NSNumber { - return max(number.int64Value, 0) - } - if let intValue = value as? Int { - return max(Int64(intValue), 0) + Text(detail) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) } - if let int64Value = value as? Int64 { - return max(int64Value, 0) + .padding(.horizontal, 10) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, minHeight: 132) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.7)) } - return 0 + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(title), \(value), \(detail)") } } diff --git a/CleanMac/en.lproj/Localizable.strings b/CleanMac/en.lproj/Localizable.strings index 31e0b3d..6ccf523 100644 --- a/CleanMac/en.lproj/Localizable.strings +++ b/CleanMac/en.lproj/Localizable.strings @@ -4,6 +4,8 @@ "section.dashboard" = "Dashboard"; "section.scan" = "Scan"; "section.results" = "Results"; +"section.diskAnalysis" = "Disk Analysis"; +"section.duplicates" = "Duplicates"; "section.applications" = "Applications"; "section.permissions" = "Permissions"; "section.settings" = "Settings"; @@ -40,6 +42,19 @@ "status.scanning" = "Scanning"; "status.autoScan.running" = "Scanning selected areas"; "status.autoScan.next" = "Next auto scan: %@"; +"status.metrics.subtitle" = "Live system stats"; +"status.metrics.cpu" = "CPU"; +"status.metrics.memory" = "Memory"; +"status.metrics.disk" = "Disk"; +"status.metrics.battery" = "Battery"; +"status.metrics.live" = "Live"; +"status.metrics.free" = "%@ free"; +"status.metrics.unavailable" = "Unavailable"; +"status.metrics.charging" = "Charging"; +"status.metrics.power" = "On power"; +"status.metrics.batteryPower" = "On battery"; +"status.metrics.rate" = "%@/s"; +"status.metrics.uptime" = "%dh %02dm"; "notification.autoScan.title" = "Auto scan finished"; "notification.autoScan.body" = "%d items, %@ found. No cleanup was performed."; "notification.test.title" = "CleanMac notifications work"; @@ -177,6 +192,12 @@ "results.reason.nodePackageCache.detail" = "This package-manager cache can usually be downloaded again when a project needs it."; "results.reason.swiftPackageCache.title" = "SwiftPM cache"; "results.reason.swiftPackageCache.detail" = "Swift Package Manager can recreate this build/cache data during future builds."; +"results.reason.developerPackageCache.title" = "Developer package cache"; +"results.reason.developerPackageCache.detail" = "Homebrew, pip, Cargo, or Gradle can download or rebuild this exact cache again."; +"results.reason.developerIDECache.title" = "Editor cache"; +"results.reason.developerIDECache.detail" = "This is an exact Cursor or VS Code cache folder; settings, extensions, workspaces, and backups are excluded."; +"results.reason.developerAITemporaryFile.title" = "AI tool temporary data"; +"results.reason.developerAITemporaryFile.detail" = "This item is inside an exact Codex or Claude cache/temp folder; projects, sessions, history, and memory are excluded."; "results.reason.staleLog.title" = "Old log"; "results.reason.staleLog.detail" = "The log looks older than the active-use window, so it is suggested for safe cleanup review."; "results.reason.rotatedLog.title" = "Rotated log"; @@ -193,6 +214,14 @@ "results.reason.installerArchive.detail" = "This looks like an installer or archive that may no longer be needed after installation or extraction."; "results.reason.xcodeBuildData.title" = "Xcode build data"; "results.reason.xcodeBuildData.detail" = "Derived Data is generated by Xcode and can grow quickly, but project-specific caches should be reviewed first."; +"results.reason.xcodeDeviceSupport.title" = "Xcode device support"; +"results.reason.xcodeDeviceSupport.detail" = "Device support data can be downloaded again, but keeping versions for devices you still debug may save time."; +"results.reason.xcodePreviewData.title" = "Xcode preview data"; +"results.reason.xcodePreviewData.detail" = "Xcode can recreate this SwiftUI preview cache when the preview is used again."; +"results.reason.staleSimulatorData.title" = "Old Simulator data"; +"results.reason.staleSimulatorData.detail" = "This user Simulator device or runtime has not changed for at least 180 days and requires manual review."; +"results.reason.xcodeArchive.title" = "Xcode archive — review required"; +"results.reason.xcodeArchive.detail" = "An .xcarchive can contain an important release build and dSYM. CleanMac never selects it automatically."; "cleanup.confirm.title" = "Move selected items to Trash?"; "cleanup.confirm.message" = "CleanMac will move %d selected item(s), %@ total, to Trash. You can restore them from Trash if needed."; @@ -255,10 +284,23 @@ "permission.state.checking" = "Checking"; "settings.title" = "Settings"; -"settings.subtitle" = "Cleanup behavior and app controls"; +"settings.subtitle" = "Behavior, startup, and app permissions"; +"settings.general" = "General"; "settings.safeMode" = "Safe Mode"; "settings.confirmBeforeCleanup" = "Confirm Before Cleanup"; "settings.showMenuBarStatus" = "Show Menu Bar Status"; +"settings.launchAtLogin" = "Launch CleanMac at login"; +"settings.launchAtLogin.status.disabled" = "Disabled in macOS"; +"settings.launchAtLogin.status.enabled" = "Enabled in macOS"; +"settings.launchAtLogin.status.requiresApproval" = "macOS approval required"; +"settings.launchAtLogin.status.unavailable" = "Unavailable"; +"settings.launchAtLogin.detail.disabled" = "CleanMac does not start automatically. Scheduled scans work only after you launch the app manually."; +"settings.launchAtLogin.detail.enabled" = "CleanMac starts after you log in to macOS without forcing the main window to open."; +"settings.launchAtLogin.detail.requiresApproval" = "Registration exists, but macOS is waiting for approval in Login Items & Extensions."; +"settings.launchAtLogin.detail.unavailable" = "macOS could not find a login service for this copy of CleanMac. Move the app to Applications and try again."; +"settings.launchAtLogin.openSystemSettings" = "Open Login Items"; +"settings.launchAtLogin.error.enable" = "macOS rejected launch-at-login registration: %@"; +"settings.launchAtLogin.error.disable" = "Could not disable launch at login: %@"; "settings.autoScan" = "Scheduled auto scan"; "settings.autoScanFrequency" = "Frequency"; "settings.autoScanFrequency.daily" = "Daily"; @@ -282,6 +324,12 @@ "area.nodeCaches.detail" = "npm, Yarn, and pnpm caches used by JavaScript projects."; "area.swiftpm.title" = "SwiftPM Build Cache"; "area.swiftpm.detail" = "Swift Package Manager cache folders used by Xcode and command-line builds."; +"area.developerPackages.title" = "Developer Package Caches"; +"area.developerPackages.detail" = "Exact Homebrew, pip, Cargo registry, and Gradle cache folders."; +"area.developerIDEs.title" = "Cursor and VS Code Caches"; +"area.developerIDEs.detail" = "Cache folders only. Settings, extensions, workspaces, and backups are never included."; +"area.developerAI.title" = "Codex and Claude Temporary Data"; +"area.developerAI.detail" = "Exact cache/temp folders only. Projects, sessions, history, shell snapshots, and memory stay untouched."; "area.logs.title" = "Logs"; "area.logs.detail" = "Rotated and older diagnostic logs, skipping likely active files."; "area.temporary.title" = "Temporary Files"; @@ -294,6 +342,14 @@ "area.installers.detail" = "DMG, PKG, ZIP, XIP, and app bundles in Downloads that need manual review."; "area.xcode.title" = "Xcode Derived Data"; "area.xcode.detail" = "Build caches that can grow quickly during development."; +"area.xcodeDeviceSupport.title" = "Xcode DeviceSupport"; +"area.xcodeDeviceSupport.detail" = "Device support versions for manual review; Xcode may download them again later."; +"area.xcodePreviews.title" = "Xcode Previews"; +"area.xcodePreviews.detail" = "Rebuildable SwiftUI preview data generated by Xcode."; +"area.xcodeSimulator.title" = "Old Simulator Data"; +"area.xcodeSimulator.detail" = "User devices and runtimes unchanged for at least 180 days. System-managed runtimes are not touched."; +"area.xcodeArchives.title" = "Xcode Archives — Review Required"; +"area.xcodeArchives.detail" = "Release archives and dSYMs are never selected automatically. Inspect every .xcarchive before cleanup."; "risk.safe" = "Safe"; "risk.review" = "Review"; @@ -336,3 +392,163 @@ "applications.removal.problems" = "Some items were not moved. Failed: %d, rejected by safety checks: %d."; "applications.detail.empty.title" = "Select an application"; "applications.detail.empty.message" = "Choose an app to inspect its path and optional exact leftovers."; + +"disk.title" = "Disk Analysis"; +"disk.subtitle" = "Understand which folders and ordinary files use your storage"; +"disk.safety.title" = "Read-only analysis"; +"disk.safety.message" = "This section never selects, cleans, moves, or adds personal files to the junk total."; +"disk.source.title" = "Source"; +"disk.source.wholeDisk" = "Whole Disk"; +"disk.source.home" = "Home"; +"disk.source.downloads" = "Downloads"; +"disk.source.custom" = "Custom Folder"; +"disk.source.custom.placeholder" = "Choose a folder to analyze"; +"disk.source.choose" = "Choose Folder"; +"disk.source.choose.confirm" = "Choose"; +"disk.source.choose.message" = "Choose a folder for read-only disk analysis."; +"disk.scan.button" = "Analyze"; +"disk.empty.title" = "Choose a folder to analyze"; +"disk.empty.message" = "Run a read-only analysis to build the disk map and large-file list."; +"disk.scanning.title" = "Analyzing folders and files"; +"disk.scanning.summary" = "%d items · %@ measured · %d large files"; +"disk.status.title" = "Analysis complete"; +"disk.status.complete" = "%@ measured across %d items. No cleanup was performed."; +"disk.status.cancelled" = "Analysis stopped. No files were changed."; +"disk.problem.title" = "Some space needs review"; +"disk.problem.issues" = "%d location(s) could not be read. Available folders are still included in the map."; +"disk.problem.generic" = "Disk analysis failed: %@"; +"disk.problem.rootUnavailable" = "The selected folder is no longer available."; +"disk.problem.notDirectory" = "The selected location is not a folder."; +"disk.problem.enumerationFailed" = "CleanMac could not start reading the selected folder."; +"disk.summary.measured" = "Allocated in this folder"; +"disk.summary.items" = "Items inspected"; +"disk.summary.largeFiles" = "Files over 50 MB"; +"disk.mode.title" = "Analysis mode"; +"disk.mode.map" = "Disk Map"; +"disk.mode.largeFiles" = "Large Files"; +"disk.map.back" = "Back one folder"; +"disk.map.files" = "Files in this folder"; +"disk.map.other" = "Other small objects"; +"disk.map.empty" = "No nested folders are available for the map."; +"disk.map.note" = "The map shows allocated file space inside the selected folder. APFS snapshots, purgeable space, and macOS system categories can make Finder totals differ."; +"disk.map.clickHint" = "Click a folder sector to open it in the map"; +"disk.map.accessibility" = "Disk map for %@, %@"; +"disk.map.tooltip.size" = "%.3f GB"; +"disk.files.threshold" = "Minimum size"; +"disk.files.sort" = "Sort"; +"disk.files.sort.size" = "Size"; +"disk.files.sort.modified" = "Modified"; +"disk.files.sort.type" = "Type"; +"disk.files.count" = "%d matching file(s)"; +"disk.files.empty.title" = "No matching large files"; +"disk.files.empty.message" = "Lower the size filter or analyze another folder."; +"disk.files.column.name" = "Name and location"; +"disk.files.column.type" = "Type"; +"disk.files.column.modified" = "Modified"; +"disk.files.column.size" = "Size"; +"disk.action.finder" = "Show in Finder"; +"disk.action.open" = "Open"; +"disk.threshold.50" = "50 MB"; +"disk.threshold.100" = "100 MB"; +"disk.threshold.500" = "500 MB"; +"disk.threshold.1000" = "1 GB"; +"disk.type.document" = "Document"; +"disk.type.image" = "Image"; +"disk.type.video" = "Video"; +"disk.type.audio" = "Audio"; +"disk.type.archive" = "Archive"; +"disk.type.application" = "Application"; +"disk.type.code" = "Code"; +"disk.type.diskImage" = "Disk image"; +"disk.type.other" = "Other"; + +"duplicates.title" = "Duplicates"; +"duplicates.subtitle" = "Find byte-identical files without adding them to junk totals"; +"duplicates.safety.title" = "One protected original always remains"; +"duplicates.safety.message" = "Nothing is selected automatically. Only explicitly checked copies can move to Trash after confirmation."; +"duplicates.source.title" = "Source"; +"duplicates.source.home" = "Home Folder"; +"duplicates.source.downloads" = "Downloads"; +"duplicates.source.custom" = "Custom Folder"; +"duplicates.source.custom.placeholder" = "Choose a folder to scan"; +"duplicates.source.choose" = "Choose Folder"; +"duplicates.source.choose.confirm" = "Choose"; +"duplicates.source.choose.message" = "Choose one folder in which CleanMac should find exact duplicate files."; +"duplicates.scan.button" = "Find Duplicates"; +"duplicates.slowMode.title" = "Slow mode for files over 500 MB"; +"duplicates.slowMode.detail" = "Includes large matching-size candidates and limits hashing to two files at a time."; +"duplicates.phase.enumerating" = "Reading file metadata"; +"duplicates.phase.grouping" = "Grouping files by exact size"; +"duplicates.phase.partial" = "Comparing first blocks with SHA-256"; +"duplicates.phase.full" = "Computing full SHA-256 for remaining candidates"; +"duplicates.phase.finalizing" = "Removing hard links and choosing protected originals"; +"duplicates.phase.completed" = "Duplicate search complete"; +"duplicates.scanning.summary" = "%d files found · %d candidates · %d hashes completed"; +"duplicates.status.title" = "Duplicate search"; +"duplicates.status.complete" = "%d groups, %d removable copies, %@ potentially reclaimable. Nothing was selected."; +"duplicates.status.cancelled" = "Duplicate search was cancelled. No files were changed."; +"duplicates.problem.title" = "Some files need attention"; +"duplicates.problem.issues" = "%d file(s) could not be read or changed during hashing and were skipped."; +"duplicates.problem.generic" = "Duplicate search failed: %@"; +"duplicates.problem.unavailable" = "The selected folder is unavailable."; +"duplicates.problem.notDirectory" = "The selected path is not a folder."; +"duplicates.summary.groups" = "Groups"; +"duplicates.summary.copies" = "Copies"; +"duplicates.summary.space" = "Reclaimable"; +"duplicates.summary.files" = "Files checked"; +"duplicates.deferred.title" = "Large candidates are waiting for slow mode"; +"duplicates.deferred.detail" = "%d files with matching sizes, %@ total, were not hidden or hashed in standard mode."; +"duplicates.deferred.enable" = "Enable Slow Mode"; +"duplicates.deferred.more" = "+%d more large candidates"; +"duplicates.selection.summary" = "%d copies selected, %@"; +"duplicates.selection.detail" = "The Original row is protected and has no checkbox."; +"duplicates.cleanup.button" = "Move Copies to Trash"; +"duplicates.cleanup.confirm.title" = "Move selected duplicate copies to Trash?"; +"duplicates.cleanup.confirm.message" = "%d explicitly selected copies, %@ total, will move to Trash. Every group keeps its protected original."; +"duplicates.cleanup.confirm.action" = "Move to Trash"; +"duplicates.cleanup.success" = "%d duplicate copies, %@ total, were moved to Trash."; +"duplicates.cleanup.problems" = "Some copies were not moved. Failed: %d, rejected by safety checks: %d."; +"duplicates.group.summary" = "%d identical files · %@ reclaimable"; +"duplicates.role.original" = "ORIGINAL"; +"duplicates.role.copy" = "COPY"; +"duplicates.empty.initial.title" = "Choose a folder and find duplicates"; +"duplicates.empty.initial.message" = "CleanMac first groups by size, then uses partial and full SHA-256 only where needed."; +"duplicates.empty.results.title" = "No exact duplicates found"; +"duplicates.empty.results.message" = "No selectable copies were created. Large candidates may still be available through Slow Mode."; + +"onboarding.appTitle" = "Meet CleanMac"; +"onboarding.skip" = "Skip introduction"; +"onboarding.progress" = "Step %d of %d"; +"onboarding.back" = "Back"; +"onboarding.next" = "Next"; +"onboarding.getStarted" = "Get Started"; +"onboarding.welcome.title" = "Welcome to CleanMac"; +"onboarding.welcome.subtitle" = "Clear and safe macOS cleanup — processed locally, with no data uploads or hidden actions."; +"onboarding.welcome.badge" = "Scan first, then confirm every action"; +"onboarding.capabilities.title" = "What CleanMac Can Do"; +"onboarding.capabilities.subtitle" = "All essential tools are available in one native app."; +"onboarding.capability.cleanup.title" = "System Cleanup"; +"onboarding.capability.cleanup.detail" = "Caches, logs, and temporary files"; +"onboarding.capability.disk.title" = "Disk Analysis"; +"onboarding.capability.disk.detail" = "Folder map and large files"; +"onboarding.capability.apps.title" = "App Removal"; +"onboarding.capability.apps.detail" = "Apps and verified leftovers"; +"onboarding.capability.safe.title" = "Safe Mode"; +"onboarding.capability.safe.detail" = "Confirmation, Trash, and restore"; +"onboarding.capability.schedule.title" = "Auto Scan"; +"onboarding.capability.schedule.detail" = "Read-only checks on your schedule"; +"onboarding.capability.local.title" = "Local Processing"; +"onboarding.capability.local.detail" = "Your data stays on this Mac"; +"onboarding.access.title" = "Full Disk Access"; +"onboarding.access.subtitle" = "Optional access lets CleanMac check protected macOS areas. Without it, CleanMac still works, but some folders remain unavailable."; +"onboarding.access.granted" = "Full Disk Access is already enabled"; +"onboarding.access.limited" = "Access is limited — enable it now or later"; +"onboarding.access.step1" = "Open System Settings"; +"onboarding.access.step2" = "Go to Privacy & Security → Full Disk Access"; +"onboarding.access.step3" = "Add or enable CleanMac"; +"onboarding.access.step4" = "Restart CleanMac if macOS asks"; +"onboarding.access.openSettings" = "Open System Settings"; +"onboarding.access.optional" = "CleanMac never opens settings or requests access without your action."; +"onboarding.ready.title" = "You're All Set!"; +"onboarding.ready.subtitle" = "Start a safe scan or open an individual tool from the sidebar."; +"onboarding.ready.badge" = "Nothing is removed without confirmation"; diff --git a/CleanMac/ru.lproj/Localizable.strings b/CleanMac/ru.lproj/Localizable.strings index 3fbf65d..d7c0420 100644 --- a/CleanMac/ru.lproj/Localizable.strings +++ b/CleanMac/ru.lproj/Localizable.strings @@ -4,6 +4,8 @@ "section.dashboard" = "Обзор"; "section.scan" = "Сканирование"; "section.results" = "Результаты"; +"section.diskAnalysis" = "Анализ диска"; +"section.duplicates" = "Дубликаты"; "section.applications" = "Приложения"; "section.permissions" = "Доступы"; "section.settings" = "Настройки"; @@ -40,6 +42,19 @@ "status.scanning" = "Сканирование"; "status.autoScan.running" = "Сканирую выбранные области"; "status.autoScan.next" = "Следующий автоскан: %@"; +"status.metrics.subtitle" = "Живая статистика системы"; +"status.metrics.cpu" = "CPU"; +"status.metrics.memory" = "Память"; +"status.metrics.disk" = "Диск"; +"status.metrics.battery" = "Батарея"; +"status.metrics.live" = "Сейчас"; +"status.metrics.free" = "%@ свободно"; +"status.metrics.unavailable" = "Недоступно"; +"status.metrics.charging" = "Заряжается"; +"status.metrics.power" = "От адаптера"; +"status.metrics.batteryPower" = "От батареи"; +"status.metrics.rate" = "%@/с"; +"status.metrics.uptime" = "%dч %02dм"; "notification.autoScan.title" = "Автосканирование завершено"; "notification.autoScan.body" = "Найдено: %d, объём: %@. Очистка не выполнялась."; "notification.test.title" = "CleanMac уведомления работают"; @@ -177,6 +192,12 @@ "results.reason.nodePackageCache.detail" = "Этот кэш npm/Yarn/pnpm обычно можно скачать заново, когда он понадобится проекту."; "results.reason.swiftPackageCache.title" = "Кэш SwiftPM"; "results.reason.swiftPackageCache.detail" = "Swift Package Manager сможет пересоздать эти данные кэша и сборки при следующих сборках."; +"results.reason.developerPackageCache.title" = "Кэш инструментов разработки"; +"results.reason.developerPackageCache.detail" = "Homebrew, pip, Cargo или Gradle смогут заново скачать или собрать данные из этой точной папки кэша."; +"results.reason.developerIDECache.title" = "Кэш редактора"; +"results.reason.developerIDECache.detail" = "Это точная папка кэша Cursor или VS Code; настройки, расширения, рабочие области и резервные копии исключены."; +"results.reason.developerAITemporaryFile.title" = "Временные данные AI-инструмента"; +"results.reason.developerAITemporaryFile.detail" = "Элемент находится в точной папке кэша или временных файлов Codex/Claude; проекты, сессии, история и память исключены."; "results.reason.staleLog.title" = "Старый лог"; "results.reason.staleLog.detail" = "Лог выглядит старше окна активного использования, поэтому предложен для безопасной проверки очистки."; "results.reason.rotatedLog.title" = "Ротированный лог"; @@ -193,6 +214,14 @@ "results.reason.installerArchive.detail" = "Файл похож на установщик или архив, который может быть уже не нужен после установки или распаковки."; "results.reason.xcodeBuildData.title" = "Данные сборки Xcode"; "results.reason.xcodeBuildData.detail" = "Derived Data создаётся Xcode и быстро растёт, но проектные кэши лучше проверять перед очисткой."; +"results.reason.xcodeDeviceSupport.title" = "Поддержка устройств Xcode"; +"results.reason.xcodeDeviceSupport.detail" = "Эти данные можно скачать заново, но версии для используемых устройств лучше сохранить, чтобы не ждать повторной загрузки."; +"results.reason.xcodePreviewData.title" = "Данные превью Xcode"; +"results.reason.xcodePreviewData.detail" = "Xcode пересоздаст этот кэш SwiftUI-превью при следующем использовании."; +"results.reason.staleSimulatorData.title" = "Старые данные Simulator"; +"results.reason.staleSimulatorData.detail" = "Это устройство или пользовательский runtime Simulator не менялись минимум 180 дней и требуют ручной проверки."; +"results.reason.xcodeArchive.title" = "Архив Xcode — требуется проверка"; +"results.reason.xcodeArchive.detail" = ".xcarchive может содержать важную релизную сборку и dSYM. CleanMac никогда не выбирает его автоматически."; "cleanup.confirm.title" = "Переместить выбранное в Корзину?"; "cleanup.confirm.message" = "CleanMac переместит выбранные элементы: %d, общий объём: %@, в Корзину. При необходимости их можно восстановить."; @@ -255,10 +284,23 @@ "permission.state.checking" = "Проверка"; "settings.title" = "Настройки"; -"settings.subtitle" = "Поведение очистки и управление приложением"; +"settings.subtitle" = "Поведение, автозапуск и доступы приложения"; +"settings.general" = "Основные"; "settings.safeMode" = "Безопасный режим"; "settings.confirmBeforeCleanup" = "Подтверждать перед очисткой"; "settings.showMenuBarStatus" = "Показывать статус в меню"; +"settings.launchAtLogin" = "Запускать CleanMac при входе"; +"settings.launchAtLogin.status.disabled" = "Выключено в macOS"; +"settings.launchAtLogin.status.enabled" = "Включено в macOS"; +"settings.launchAtLogin.status.requiresApproval" = "Требуется одобрение macOS"; +"settings.launchAtLogin.status.unavailable" = "Недоступно"; +"settings.launchAtLogin.detail.disabled" = "CleanMac не запускается автоматически. Автосканирование работает только после ручного запуска приложения."; +"settings.launchAtLogin.detail.enabled" = "CleanMac запускается после входа в macOS без принудительного открытия главного окна."; +"settings.launchAtLogin.detail.requiresApproval" = "Регистрация создана, но macOS ждёт подтверждения в разделе «Объекты входа и расширения»."; +"settings.launchAtLogin.detail.unavailable" = "macOS не нашла службу запуска для этой копии CleanMac. Перемести приложение в «Программы» и повтори попытку."; +"settings.launchAtLogin.openSystemSettings" = "Открыть объекты входа"; +"settings.launchAtLogin.error.enable" = "macOS отклонила регистрацию запуска при входе: %@"; +"settings.launchAtLogin.error.disable" = "Не удалось отключить запуск при входе: %@"; "settings.autoScan" = "Автосканирование по расписанию"; "settings.autoScanFrequency" = "Периодичность"; "settings.autoScanFrequency.daily" = "Каждый день"; @@ -282,6 +324,12 @@ "area.nodeCaches.detail" = "Кэши npm, Yarn и pnpm для JavaScript-проектов."; "area.swiftpm.title" = "Кэш сборки SwiftPM"; "area.swiftpm.detail" = "Кэши Swift Package Manager для Xcode и командной строки."; +"area.developerPackages.title" = "Кэши инструментов разработки"; +"area.developerPackages.detail" = "Точные папки кэшей Homebrew, pip, Cargo registry и Gradle."; +"area.developerIDEs.title" = "Кэши Cursor и VS Code"; +"area.developerIDEs.detail" = "Только кэши. Настройки, расширения, рабочие области и резервные копии не включаются."; +"area.developerAI.title" = "Временные данные Codex и Claude"; +"area.developerAI.detail" = "Только точные папки кэша и временных файлов. Проекты, сессии, история, shell snapshots и память не затрагиваются."; "area.logs.title" = "Логи"; "area.logs.detail" = "Старые и ротированные диагностические логи, без активных файлов."; "area.temporary.title" = "Временные файлы"; @@ -294,6 +342,14 @@ "area.installers.detail" = "DMG, PKG, ZIP, XIP и .app в Загрузках, требующие ручной проверки."; "area.xcode.title" = "Derived Data Xcode"; "area.xcode.detail" = "Кэши сборки, которые быстро растут при разработке."; +"area.xcodeDeviceSupport.title" = "Xcode DeviceSupport"; +"area.xcodeDeviceSupport.detail" = "Версии поддержки устройств для ручной проверки; позже Xcode может скачать их заново."; +"area.xcodePreviews.title" = "Превью Xcode"; +"area.xcodePreviews.detail" = "Пересоздаваемые данные SwiftUI-превью, созданные Xcode."; +"area.xcodeSimulator.title" = "Старые данные Simulator"; +"area.xcodeSimulator.detail" = "Устройства и пользовательские runtime без изменений минимум 180 дней. Системные runtime не затрагиваются."; +"area.xcodeArchives.title" = "Архивы Xcode — Требует проверки"; +"area.xcodeArchives.detail" = "Релизные архивы и dSYM никогда не выбираются автоматически. Проверь каждый .xcarchive перед очисткой."; "risk.safe" = "Безопасно"; "risk.review" = "Проверить"; @@ -336,3 +392,163 @@ "applications.removal.problems" = "Некоторые элементы не перемещены. Ошибок: %d, отклонено проверкой безопасности: %d."; "applications.detail.empty.title" = "Выбери приложение"; "applications.detail.empty.message" = "Выбери программу, чтобы проверить её путь и необязательные точные остатки."; + +"disk.title" = "Анализ диска"; +"disk.subtitle" = "Показывает, какие папки и обычные файлы занимают место"; +"disk.safety.title" = "Только анализ"; +"disk.safety.message" = "Этот раздел ничего не выбирает, не очищает, не перемещает и не добавляет личные файлы в объём мусора."; +"disk.source.title" = "Источник"; +"disk.source.wholeDisk" = "Весь диск"; +"disk.source.home" = "Домашняя папка"; +"disk.source.downloads" = "Загрузки"; +"disk.source.custom" = "Своя папка"; +"disk.source.custom.placeholder" = "Выбери папку для анализа"; +"disk.source.choose" = "Выбрать папку"; +"disk.source.choose.confirm" = "Выбрать"; +"disk.source.choose.message" = "Выбери папку для безопасного анализа диска только на чтение."; +"disk.scan.button" = "Анализировать"; +"disk.empty.title" = "Выбери папку для анализа"; +"disk.empty.message" = "Запусти безопасный анализ, чтобы построить карту диска и список больших файлов."; +"disk.scanning.title" = "Анализирую папки и файлы"; +"disk.scanning.summary" = "Объектов: %d · измерено %@ · больших файлов: %d"; +"disk.status.title" = "Анализ завершён"; +"disk.status.complete" = "Измерено %@ в %d объектах. Очистка не выполнялась."; +"disk.status.cancelled" = "Анализ остановлен. Файлы не изменялись."; +"disk.problem.title" = "Не всё пространство удалось проверить"; +"disk.problem.issues" = "Не удалось прочитать расположений: %d. Доступные папки всё равно показаны на карте."; +"disk.problem.generic" = "Ошибка анализа диска: %@"; +"disk.problem.rootUnavailable" = "Выбранная папка больше недоступна."; +"disk.problem.notDirectory" = "Выбранное расположение не является папкой."; +"disk.problem.enumerationFailed" = "CleanMac не смог начать чтение выбранной папки."; +"disk.summary.measured" = "Занято в этой папке"; +"disk.summary.items" = "Проверено объектов"; +"disk.summary.largeFiles" = "Файлов больше 50 МБ"; +"disk.mode.title" = "Режим анализа"; +"disk.mode.map" = "Карта диска"; +"disk.mode.largeFiles" = "Большие файлы"; +"disk.map.back" = "Назад на одну папку"; +"disk.map.files" = "Файлы в этой папке"; +"disk.map.other" = "Другие небольшие объекты"; +"disk.map.empty" = "Для карты нет вложенных папок."; +"disk.map.note" = "Карта показывает выделенное место файлов внутри выбранной папки. Снимки APFS, очищаемое пространство и системные категории macOS могут отличаться от показателей Finder."; +"disk.map.clickHint" = "Нажми на сектор папки, чтобы перейти внутрь"; +"disk.map.accessibility" = "Карта диска для %@, %@"; +"disk.map.tooltip.size" = "%.3f ГБ"; +"disk.files.threshold" = "Минимальный размер"; +"disk.files.sort" = "Сортировка"; +"disk.files.sort.size" = "Размер"; +"disk.files.sort.modified" = "Изменено"; +"disk.files.sort.type" = "Тип"; +"disk.files.count" = "Подходящих файлов: %d"; +"disk.files.empty.title" = "Большие файлы не найдены"; +"disk.files.empty.message" = "Уменьши фильтр размера или проанализируй другую папку."; +"disk.files.column.name" = "Имя и расположение"; +"disk.files.column.type" = "Тип"; +"disk.files.column.modified" = "Изменено"; +"disk.files.column.size" = "Размер"; +"disk.action.finder" = "Показать в Finder"; +"disk.action.open" = "Открыть"; +"disk.threshold.50" = "50 МБ"; +"disk.threshold.100" = "100 МБ"; +"disk.threshold.500" = "500 МБ"; +"disk.threshold.1000" = "1 ГБ"; +"disk.type.document" = "Документ"; +"disk.type.image" = "Изображение"; +"disk.type.video" = "Видео"; +"disk.type.audio" = "Аудио"; +"disk.type.archive" = "Архив"; +"disk.type.application" = "Приложение"; +"disk.type.code" = "Код"; +"disk.type.diskImage" = "Образ диска"; +"disk.type.other" = "Другое"; + +"duplicates.title" = "Дубликаты"; +"duplicates.subtitle" = "Поиск полностью одинаковых файлов без добавления в показатель мусора"; +"duplicates.safety.title" = "Один защищённый оригинал всегда остаётся"; +"duplicates.safety.message" = "Ничего не выбирается автоматически. Только явно отмеченные копии можно переместить в Корзину после подтверждения."; +"duplicates.source.title" = "Источник"; +"duplicates.source.home" = "Домашняя папка"; +"duplicates.source.downloads" = "Загрузки"; +"duplicates.source.custom" = "Своя папка"; +"duplicates.source.custom.placeholder" = "Выбери папку для поиска"; +"duplicates.source.choose" = "Выбрать папку"; +"duplicates.source.choose.confirm" = "Выбрать"; +"duplicates.source.choose.message" = "Выбери одну папку, в которой CleanMac должен найти точные дубликаты файлов."; +"duplicates.scan.button" = "Найти дубликаты"; +"duplicates.slowMode.title" = "Медленный режим для файлов больше 500 МБ"; +"duplicates.slowMode.detail" = "Включает крупные кандидаты одинакового размера и хеширует не более двух файлов одновременно."; +"duplicates.phase.enumerating" = "Читаю метаданные файлов"; +"duplicates.phase.grouping" = "Группирую файлы по точному размеру"; +"duplicates.phase.partial" = "Сравниваю первые блоки через SHA-256"; +"duplicates.phase.full" = "Вычисляю полный SHA-256 оставшихся кандидатов"; +"duplicates.phase.finalizing" = "Исключаю hard links и выбираю защищённые оригиналы"; +"duplicates.phase.completed" = "Поиск дубликатов завершён"; +"duplicates.scanning.summary" = "Найдено файлов: %d · кандидатов: %d · хешей готово: %d"; +"duplicates.status.title" = "Поиск дубликатов"; +"duplicates.status.complete" = "Групп: %d, удаляемых копий: %d, потенциально %@. Ничего не выбрано."; +"duplicates.status.cancelled" = "Поиск дубликатов отменён. Файлы не изменялись."; +"duplicates.problem.title" = "Некоторые файлы требуют внимания"; +"duplicates.problem.issues" = "Не удалось прочитать или стабильно хешировать файлов: %d. Они пропущены."; +"duplicates.problem.generic" = "Ошибка поиска дубликатов: %@"; +"duplicates.problem.unavailable" = "Выбранная папка недоступна."; +"duplicates.problem.notDirectory" = "Выбранный путь не является папкой."; +"duplicates.summary.groups" = "Группы"; +"duplicates.summary.copies" = "Копии"; +"duplicates.summary.space" = "Можно освободить"; +"duplicates.summary.files" = "Проверено файлов"; +"duplicates.deferred.title" = "Крупные кандидаты ожидают медленный режим"; +"duplicates.deferred.detail" = "Файлов одинакового размера: %d, общий объём: %@. Они не скрыты и не хешировались в обычном режиме."; +"duplicates.deferred.enable" = "Включить медленный режим"; +"duplicates.deferred.more" = "+%d крупных кандидатов"; +"duplicates.selection.summary" = "Выбрано копий: %d, объём: %@"; +"duplicates.selection.detail" = "Строка «Оригинал» защищена и не имеет флажка."; +"duplicates.cleanup.button" = "Переместить копии в Корзину"; +"duplicates.cleanup.confirm.title" = "Переместить выбранные копии в Корзину?"; +"duplicates.cleanup.confirm.message" = "Явно выбрано копий: %d, общий объём: %@. Они будут перемещены в Корзину, а защищённый оригинал каждой группы останется."; +"duplicates.cleanup.confirm.action" = "Переместить в Корзину"; +"duplicates.cleanup.success" = "Перемещено копий: %d, общий объём: %@."; +"duplicates.cleanup.problems" = "Некоторые копии не перемещены. Ошибок: %d, отклонено проверкой безопасности: %d."; +"duplicates.group.summary" = "Одинаковых файлов: %d · можно освободить %@"; +"duplicates.role.original" = "ОРИГИНАЛ"; +"duplicates.role.copy" = "КОПИЯ"; +"duplicates.empty.initial.title" = "Выбери папку и найди дубликаты"; +"duplicates.empty.initial.message" = "CleanMac сначала группирует по размеру, затем использует частичный и полный SHA-256 только для оставшихся кандидатов."; +"duplicates.empty.results.title" = "Точных дубликатов не найдено"; +"duplicates.empty.results.message" = "Копий для выбора нет. Крупные кандидаты могут быть доступны через медленный режим."; + +"onboarding.appTitle" = "Знакомство с CleanMac"; +"onboarding.skip" = "Пропустить знакомство"; +"onboarding.progress" = "Шаг %d из %d"; +"onboarding.back" = "Назад"; +"onboarding.next" = "Далее"; +"onboarding.getStarted" = "Начать"; +"onboarding.welcome.title" = "Добро пожаловать в CleanMac"; +"onboarding.welcome.subtitle" = "Понятная и безопасная очистка macOS — локально, без отправки данных и скрытых действий."; +"onboarding.welcome.badge" = "Сначала проверка, затем ваше подтверждение"; +"onboarding.capabilities.title" = "Что умеет CleanMac"; +"onboarding.capabilities.subtitle" = "Все основные инструменты собраны в одном нативном приложении."; +"onboarding.capability.cleanup.title" = "Очистка системы"; +"onboarding.capability.cleanup.detail" = "Кэши, журналы и временные файлы"; +"onboarding.capability.disk.title" = "Анализ диска"; +"onboarding.capability.disk.detail" = "Карта папок и большие файлы"; +"onboarding.capability.apps.title" = "Удаление приложений"; +"onboarding.capability.apps.detail" = "Приложения и проверенные остатки"; +"onboarding.capability.safe.title" = "Безопасный режим"; +"onboarding.capability.safe.detail" = "Подтверждение, Корзина и восстановление"; +"onboarding.capability.schedule.title" = "Автосканирование"; +"onboarding.capability.schedule.detail" = "Проверки по выбранному расписанию"; +"onboarding.capability.local.title" = "Локальная работа"; +"onboarding.capability.local.detail" = "Данные остаются только на этом Mac"; +"onboarding.access.title" = "Полный доступ к диску"; +"onboarding.access.subtitle" = "Необязательный доступ позволяет проверить защищённые области macOS. Без него CleanMac продолжит работать, но часть папок будет недоступна."; +"onboarding.access.granted" = "Полный доступ к диску уже включён"; +"onboarding.access.limited" = "Доступ ограничен — его можно включить сейчас или позже"; +"onboarding.access.step1" = "Откройте Системные настройки"; +"onboarding.access.step2" = "Перейдите в Конфиденциальность и безопасность → Полный доступ к диску"; +"onboarding.access.step3" = "Добавьте или включите CleanMac"; +"onboarding.access.step4" = "Перезапустите CleanMac, если macOS попросит"; +"onboarding.access.openSettings" = "Открыть Системные настройки"; +"onboarding.access.optional" = "CleanMac не открывает настройки и не запрашивает доступ без вашего действия."; +"onboarding.ready.title" = "Всё готово!"; +"onboarding.ready.subtitle" = "Запустите безопасное сканирование или откройте отдельный инструмент в боковом меню."; +"onboarding.ready.badge" = "Ничего не удаляется без подтверждения"; diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift b/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift index 78ded96..b859cea 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift @@ -5,12 +5,19 @@ public enum CleanupCategory: String, CaseIterable, Codable, Identifiable, Sendab case browserCaches = "browser-cache" case nodePackageCaches = "node-cache" case swiftPackageBuilds = "swiftpm" + case developerPackageCaches = "developer-package-cache" + case developerIDECaches = "developer-ide-cache" + case developerAITemporaryFiles = "developer-ai-temp" case logs case temporaryFiles = "temp" case trash case downloads case downloadedInstallers = "installers" case xcodeDerivedData = "xcode" + case xcodeDeviceSupport = "xcode-device-support" + case xcodePreviews = "xcode-previews" + case xcodeSimulatorData = "xcode-simulator" + case xcodeArchives = "xcode-archives" public var id: String { rawValue } } @@ -25,6 +32,9 @@ public enum CleanupScanReason: String, Equatable, Sendable { case browserCache case nodePackageCache case swiftPackageCache + case developerPackageCache + case developerIDECache + case developerAITemporaryFile case staleLog case rotatedLog case staleTemporary @@ -33,6 +43,10 @@ public enum CleanupScanReason: String, Equatable, Sendable { case oldDownload case installerArchive case xcodeBuildData + case xcodeDeviceSupport + case xcodePreviewData + case staleSimulatorData + case xcodeArchive } public struct CleanupScanOptions: Equatable, Sendable { @@ -42,6 +56,7 @@ public struct CleanupScanOptions: Equatable, Sendable { public var staleDownloadAge: TimeInterval public var staleLogAge: TimeInterval public var staleTemporaryAge: TimeInterval + public var staleDeveloperDataAge: TimeInterval public init( maxItemsPerCategory: Int = 80, @@ -49,7 +64,8 @@ public struct CleanupScanOptions: Equatable, Sendable { largeDownloadThresholdBytes: Int64 = 100 * 1024 * 1024, staleDownloadAge: TimeInterval = 30 * 24 * 60 * 60, staleLogAge: TimeInterval = 7 * 24 * 60 * 60, - staleTemporaryAge: TimeInterval = 24 * 60 * 60 + staleTemporaryAge: TimeInterval = 24 * 60 * 60, + staleDeveloperDataAge: TimeInterval = 180 * 24 * 60 * 60 ) { self.maxItemsPerCategory = max(1, maxItemsPerCategory) self.maxDescendantsPerItem = max(1, maxDescendantsPerItem) @@ -57,6 +73,7 @@ public struct CleanupScanOptions: Equatable, Sendable { self.staleDownloadAge = max(0, staleDownloadAge) self.staleLogAge = max(0, staleLogAge) self.staleTemporaryAge = max(0, staleTemporaryAge) + self.staleDeveloperDataAge = max(0, staleDeveloperDataAge) } } diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift b/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift index c98e08d..550d573 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift @@ -29,6 +29,31 @@ struct CleanupRootResolver { ] case .swiftPackageBuilds: [homeDirectory.appending(path: "Library/Caches/org.swift.swiftpm", directoryHint: .isDirectory)] + case .developerPackageCaches: + [ + homeDirectory.appending(path: "Library/Caches/Homebrew", directoryHint: .isDirectory), + homeDirectory.appending(path: "Library/Caches/pip", directoryHint: .isDirectory), + homeDirectory.appending(path: ".cargo/registry/cache", directoryHint: .isDirectory), + homeDirectory.appending(path: ".cargo/registry/src", directoryHint: .isDirectory), + homeDirectory.appending(path: ".gradle/caches", directoryHint: .isDirectory) + ] + case .developerIDECaches: + electronCacheRoots(for: "Cursor") + + electronCacheRoots(for: "Code") + + [ + homeDirectory.appending(path: "Library/Caches/com.todesktop.230313mzl4w4u92", directoryHint: .isDirectory), + homeDirectory.appending(path: "Library/Caches/com.microsoft.VSCode", directoryHint: .isDirectory) + ] + case .developerAITemporaryFiles: + [ + homeDirectory.appending(path: ".codex/.tmp", directoryHint: .isDirectory), + homeDirectory.appending(path: ".codex/tmp", directoryHint: .isDirectory), + homeDirectory.appending(path: ".codex/cache", directoryHint: .isDirectory), + homeDirectory.appending(path: "Library/Caches/com.openai.codex", directoryHint: .isDirectory), + homeDirectory.appending(path: ".claude/cache", directoryHint: .isDirectory), + homeDirectory.appending(path: ".claude/paste-cache", directoryHint: .isDirectory), + homeDirectory.appending(path: "Library/Caches/com.anthropic.claudefordesktop", directoryHint: .isDirectory) + ] case .logs: [homeDirectory.appending(path: "Library/Logs", directoryHint: .isDirectory)] case .temporaryFiles: @@ -41,9 +66,29 @@ struct CleanupRootResolver { [homeDirectory.appending(path: "Downloads", directoryHint: .isDirectory)] case .xcodeDerivedData: [homeDirectory.appending(path: "Library/Developer/Xcode/DerivedData", directoryHint: .isDirectory)] + case .xcodeDeviceSupport: + [homeDirectory.appending(path: "Library/Developer/Xcode/iOS DeviceSupport", directoryHint: .isDirectory)] + case .xcodePreviews: + [homeDirectory.appending(path: "Library/Developer/Xcode/UserData/Previews", directoryHint: .isDirectory)] + case .xcodeSimulatorData: + [ + homeDirectory.appending(path: "Library/Developer/CoreSimulator/Devices", directoryHint: .isDirectory), + homeDirectory.appending(path: "Library/Developer/CoreSimulator/Profiles/Runtimes", directoryHint: .isDirectory) + ] + case .xcodeArchives: + [homeDirectory.appending(path: "Library/Developer/Xcode/Archives", directoryHint: .isDirectory)] } } + private func electronCacheRoots(for applicationSupportName: String) -> [URL] { + let base = homeDirectory.appending( + path: "Library/Application Support/\(applicationSupportName)", + directoryHint: .isDirectory + ) + return ["Cache", "Code Cache", "GPUCache", "CachedData", "CachedProfilesData"] + .map { base.appending(path: $0, directoryHint: .isDirectory) } + } + func rootURL(for category: CleanupCategory) -> URL { rootURLs(for: category)[0] } diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift b/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift index db37d35..bebc3c0 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift @@ -71,6 +71,30 @@ public struct CleanupPlanner { )) } + if item.category == .xcodeArchives, url.pathExtension.lowercased() != "xcarchive" { + return .rejected(rejection( + for: item, + reason: .outsideAllowedRoot, + message: "Only individual Xcode archive bundles can be cleaned." + )) + } + + let directChildCategories: Set = [ + .xcodeDeviceSupport, + .xcodePreviews, + .xcodeSimulatorData + ] + if directChildCategories.contains(item.category) { + let canonicalParentPath = url.deletingLastPathComponent().canonicalPath + guard rootPaths.contains(canonicalParentPath) else { + return .rejected(rejection( + for: item, + reason: .outsideAllowedRoot, + message: "Only direct children of this cleanup root can be cleaned." + )) + } + } + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) guard values?.isSymbolicLink != true else { return .rejected(rejection( diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift b/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift index 7f28487..28df97a 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift @@ -143,11 +143,7 @@ public struct CleanupScanner { let childURLs: [URL] do { - childURLs = try fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: Array(resourceKeys), - options: [.skipsPackageDescendants] - ) + childURLs = try candidateURLs(in: rootURL, category: category) } catch { issues.append(CleanupScanIssue( category: category, @@ -159,7 +155,7 @@ public struct CleanupScanner { let remainingItemLimit = max(0, options.maxItemsPerCategory - items.count) let candidateURLs = childURLs - .filter { shouldInclude($0, in: category) } + .filter { shouldInclude($0, in: category, options: options) } .prefix(remainingItemLimit) for (candidateIndex, candidateURL) in candidateURLs.enumerated() { @@ -226,9 +222,16 @@ public struct CleanupScanner { private func priority(for category: CleanupCategory) -> Int { switch category { - case .browserCaches, .nodePackageCaches, .swiftPackageBuilds, .downloadedInstallers: + case .browserCaches, + .nodePackageCaches, + .swiftPackageBuilds, + .developerPackageCaches, + .developerIDECaches, + .developerAITemporaryFiles, + .downloadedInstallers, + .xcodePreviews: 3 - case .xcodeDerivedData: + case .xcodeDerivedData, .xcodeDeviceSupport, .xcodeSimulatorData, .xcodeArchives: 2 case .userCaches, .downloads: 1 @@ -237,12 +240,51 @@ public struct CleanupScanner { } } - private func shouldInclude(_ url: URL, in category: CleanupCategory) -> Bool { + private func candidateURLs(in rootURL: URL, category: CleanupCategory) throws -> [URL] { + let directChildren = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsPackageDescendants] + ) + + guard category == .xcodeArchives else { + return directChildren + } + + return directChildren.flatMap { childURL in + if childURL.pathExtension.lowercased() == "xcarchive" { + return [childURL] + } + + let values = try? childURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values?.isDirectory == true else { + return [] + } + + let nestedChildren = try? fileManager.contentsOfDirectory( + at: childURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsPackageDescendants] + ) + return nestedChildren?.filter { $0.pathExtension.lowercased() == "xcarchive" } ?? [] + } + } + + private func shouldInclude( + _ url: URL, + in category: CleanupCategory, + options: CleanupScanOptions + ) -> Bool { switch category { case .downloadedInstallers: - isDownloadedInstallerOrArchive(url) + return isDownloadedInstallerOrArchive(url) + case .xcodeArchives: + return url.pathExtension.lowercased() == "xcarchive" + case .xcodeSimulatorData: + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + return isOlderThan(values?.contentModificationDate, options.staleDeveloperDataAge) default: - true + return true } } @@ -359,9 +401,24 @@ public struct CleanupScanner { private func risk(for category: CleanupCategory) -> CleanupRiskLevel { switch category { - case .userCaches, .browserCaches, .nodePackageCaches, .swiftPackageBuilds, .logs, .temporaryFiles: + case .userCaches, + .browserCaches, + .nodePackageCaches, + .swiftPackageBuilds, + .developerPackageCaches, + .developerIDECaches, + .developerAITemporaryFiles, + .logs, + .temporaryFiles, + .xcodePreviews: .safe - case .trash, .downloads, .downloadedInstallers, .xcodeDerivedData: + case .trash, + .downloads, + .downloadedInstallers, + .xcodeDerivedData, + .xcodeDeviceSupport, + .xcodeSimulatorData, + .xcodeArchives: .review } } @@ -382,6 +439,12 @@ public struct CleanupScanner { return [.nodePackageCache] case .swiftPackageBuilds: return [.swiftPackageCache] + case .developerPackageCaches: + return [.developerPackageCache] + case .developerIDECaches: + return [.developerIDECache] + case .developerAITemporaryFiles: + return [.developerAITemporaryFile] case .downloads: var reasons: [CleanupScanReason] = [] if isDownloadedInstallerOrArchive(url) { @@ -411,6 +474,16 @@ public struct CleanupScanner { return isDownloadedInstallerOrArchive(url) ? [.installerArchive] : [] case .xcodeDerivedData: return [.xcodeBuildData] + case .xcodeDeviceSupport: + return [.xcodeDeviceSupport] + case .xcodePreviews: + return [.xcodePreviewData] + case .xcodeSimulatorData: + return isOlderThan(resourceValues?.contentModificationDate, options.staleDeveloperDataAge) + ? [.staleSimulatorData] + : [] + case .xcodeArchives: + return url.pathExtension.lowercased() == "xcarchive" ? [.xcodeArchive] : [] } } diff --git a/CleanMacCore/Sources/CleanMacCore/DiskAnalyzer.swift b/CleanMacCore/Sources/CleanMacCore/DiskAnalyzer.swift new file mode 100644 index 0000000..d9425df --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/DiskAnalyzer.swift @@ -0,0 +1,556 @@ +import Foundation + +public enum DiskAnalysisFileType: String, CaseIterable, Sendable { + case document + case image + case video + case audio + case archive + case application + case code + case diskImage + case other + + public static func classify(pathExtension: String) -> DiskAnalysisFileType { + switch pathExtension.lowercased() { + case "pdf", "doc", "docx", "pages", "rtf", "txt", "odt", "xls", "xlsx", "numbers", "csv", "ppt", "pptx", "key": + .document + case "jpg", "jpeg", "png", "gif", "bmp", "tiff", "heic", "webp", "svg": + .image + case "mp4", "mov", "avi", "mkv", "wmv", "webm", "m4v": + .video + case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma": + .audio + case "zip", "gz", "tar", "rar", "7z", "bz2", "xz", "xip": + .archive + case "app", "pkg", "mpkg": + .application + case "swift", "h", "m", "mm", "c", "cpp", "py", "js", "ts", "java", "rs", "go", "rb", "php", "css", "html", "sh", "json", "xml", "yaml", "yml", "toml", "plist": + .code + case "dmg", "iso", "img", "sparseimage": + .diskImage + default: + .other + } + } +} + +public struct DiskAnalysisFile: Identifiable, Equatable, Sendable { + public let id: String + public let path: String + public let name: String + public let sizeBytes: Int64 + public let modifiedAt: Date? + public let fileType: DiskAnalysisFileType + + public init( + path: String, + name: String, + sizeBytes: Int64, + modifiedAt: Date?, + fileType: DiskAnalysisFileType + ) { + self.id = path + self.path = path + self.name = name + self.sizeBytes = max(0, sizeBytes) + self.modifiedAt = modifiedAt + self.fileType = fileType + } +} + +public struct DiskAnalysisNode: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let path: String? + public let sizeBytes: Int64 + public let isDirectory: Bool + public let isAggregate: Bool + public let children: [DiskAnalysisNode] + + public init( + id: String, + name: String, + path: String?, + sizeBytes: Int64, + isDirectory: Bool, + isAggregate: Bool = false, + children: [DiskAnalysisNode] = [] + ) { + self.id = id + self.name = name + self.path = path + self.sizeBytes = max(0, sizeBytes) + self.isDirectory = isDirectory + self.isAggregate = isAggregate + self.children = children + } +} + +public enum DiskAnalysisProgressPhase: String, Sendable { + case preparing + case scanning + case summarizing + case completed +} + +public struct DiskAnalysisProgress: Equatable, Sendable { + public let phase: DiskAnalysisProgressPhase + public let currentPath: String? + public let visitedItemCount: Int + public let measuredSizeBytes: Int64 + public let largeFileCount: Int + + public init( + phase: DiskAnalysisProgressPhase, + currentPath: String?, + visitedItemCount: Int, + measuredSizeBytes: Int64, + largeFileCount: Int + ) { + self.phase = phase + self.currentPath = currentPath + self.visitedItemCount = max(0, visitedItemCount) + self.measuredSizeBytes = max(0, measuredSizeBytes) + self.largeFileCount = max(0, largeFileCount) + } +} + +public struct DiskAnalysisIssue: Equatable, Sendable { + public let path: String + public let message: String + + public init(path: String, message: String) { + self.path = path + self.message = message + } +} + +public struct DiskAnalysisReport: Equatable, Sendable { + public let root: DiskAnalysisNode + public let largeFiles: [DiskAnalysisFile] + public let issues: [DiskAnalysisIssue] + public let visitedItemCount: Int + public let durationSeconds: TimeInterval + + public init( + root: DiskAnalysisNode, + largeFiles: [DiskAnalysisFile], + issues: [DiskAnalysisIssue], + visitedItemCount: Int, + durationSeconds: TimeInterval + ) { + self.root = root + self.largeFiles = largeFiles + self.issues = issues + self.visitedItemCount = max(0, visitedItemCount) + self.durationSeconds = max(0, durationSeconds) + } +} + +public struct DiskAnalysisOptions: Equatable, Sendable { + public var minimumLargeFileSizeBytes: Int64 + public var maximumLargeFiles: Int + public var maximumTreeDepth: Int + public var maximumChildrenPerNode: Int + public var maximumTreeNodes: Int + public var progressInterval: Int + + public init( + minimumLargeFileSizeBytes: Int64 = 50 * 1024 * 1024, + maximumLargeFiles: Int = 5_000, + maximumTreeDepth: Int = 6, + maximumChildrenPerNode: Int = 18, + maximumTreeNodes: Int = 5_000, + progressInterval: Int = 200 + ) { + self.minimumLargeFileSizeBytes = max(1, minimumLargeFileSizeBytes) + self.maximumLargeFiles = max(1, maximumLargeFiles) + self.maximumTreeDepth = max(1, maximumTreeDepth) + self.maximumChildrenPerNode = max(2, maximumChildrenPerNode) + self.maximumTreeNodes = max(32, maximumTreeNodes) + self.progressInterval = max(1, progressInterval) + } +} + +public enum DiskAnalyzerError: Error, Equatable, Sendable { + case rootUnavailable + case rootIsNotDirectory + case enumerationFailed +} + +public struct DiskAnalyzer { + private let fileManager: FileManager + + private let resourceKeys: Set = [ + .isDirectoryKey, + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + .fileAllocatedSizeKey, + .totalFileAllocatedSizeKey, + .contentModificationDateKey, + .nameKey + ] + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + /// Performs a read-only recursive scan. The returned tree is deliberately + /// bounded; file rows are separate and never become cleanup candidates. + public func scan( + root rootURL: URL, + options: DiskAnalysisOptions = DiskAnalysisOptions(), + progress: (@Sendable (DiskAnalysisProgress) -> Void)? = nil, + isCancelled: @Sendable () -> Bool = { false } + ) throws -> DiskAnalysisReport { + let startedAt = Date() + let canonicalRoot = rootURL.resolvingSymlinksInPath().standardizedFileURL + var rootIsDirectory: ObjCBool = false + + guard fileManager.fileExists(atPath: canonicalRoot.path, isDirectory: &rootIsDirectory) else { + throw DiskAnalyzerError.rootUnavailable + } + guard rootIsDirectory.boolValue else { + throw DiskAnalyzerError.rootIsNotDirectory + } + + try checkCancellation(isCancelled) + progress?(DiskAnalysisProgress( + phase: .preparing, + currentPath: canonicalRoot.path, + visitedItemCount: 0, + measuredSizeBytes: 0, + largeFileCount: 0 + )) + + let rootName = canonicalRoot.lastPathComponent.isEmpty ? canonicalRoot.path : canonicalRoot.lastPathComponent + let rootAccumulator = NodeAccumulator( + name: rootName, + path: canonicalRoot.path, + isDirectory: true + ) + var treeNodeCount = 1 + var visitedItemCount = 0 + var totalSizeBytes: Int64 = 0 + var largeFiles: [DiskAnalysisFile] = [] + var issues: [DiskAnalysisIssue] = [] + + guard let enumerator = fileManager.enumerator( + at: canonicalRoot, + includingPropertiesForKeys: Array(resourceKeys), + options: [], + errorHandler: { url, error in + issues.append(DiskAnalysisIssue(path: url.path, message: error.localizedDescription)) + return true + } + ) else { + throw DiskAnalyzerError.enumerationFailed + } + + while let entry = enumerator.nextObject() as? URL { + try checkCancellation(isCancelled) + visitedItemCount += 1 + + guard let values = try? entry.resourceValues(forKeys: resourceKeys) else { + issues.append(DiskAnalysisIssue(path: entry.path, message: "Metadata unavailable")) + continue + } + + if values.isSymbolicLink == true { + if values.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + + let relativeComponents = relativePathComponents(of: entry, beneath: canonicalRoot) + if values.isDirectory == true { + registerDirectory( + components: relativeComponents, + rootURL: canonicalRoot, + rootAccumulator: rootAccumulator, + maximumDepth: options.maximumTreeDepth, + maximumNodes: options.maximumTreeNodes, + treeNodeCount: &treeNodeCount + ) + } else if values.isRegularFile == true { + let sizeBytes = allocatedSize(from: values) + totalSizeBytes += sizeBytes + addFileSize( + sizeBytes, + directoryComponents: Array(relativeComponents.dropLast()), + rootURL: canonicalRoot, + rootAccumulator: rootAccumulator, + maximumDepth: options.maximumTreeDepth, + maximumNodes: options.maximumTreeNodes, + treeNodeCount: &treeNodeCount + ) + + if sizeBytes >= options.minimumLargeFileSizeBytes { + largeFiles.append(DiskAnalysisFile( + path: entry.path, + name: values.name ?? entry.lastPathComponent, + sizeBytes: sizeBytes, + modifiedAt: values.contentModificationDate, + fileType: .classify(pathExtension: entry.pathExtension) + )) + } + } + + if visitedItemCount.isMultiple(of: options.progressInterval) { + progress?(DiskAnalysisProgress( + phase: .scanning, + currentPath: entry.path, + visitedItemCount: visitedItemCount, + measuredSizeBytes: totalSizeBytes, + largeFileCount: largeFiles.count + )) + } + } + + try checkCancellation(isCancelled) + progress?(DiskAnalysisProgress( + phase: .summarizing, + currentPath: canonicalRoot.path, + visitedItemCount: visitedItemCount, + measuredSizeBytes: totalSizeBytes, + largeFileCount: largeFiles.count + )) + + let sortedLargeFiles = largeFiles + .sorted { + if $0.sizeBytes == $1.sizeBytes { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return $0.sizeBytes > $1.sizeBytes + } + .prefix(options.maximumLargeFiles) + + let rootNode = finalize( + rootAccumulator, + maximumChildren: options.maximumChildrenPerNode, + depth: 0 + ) + + progress?(DiskAnalysisProgress( + phase: .completed, + currentPath: canonicalRoot.path, + visitedItemCount: visitedItemCount, + measuredSizeBytes: rootNode.sizeBytes, + largeFileCount: sortedLargeFiles.count + )) + + return DiskAnalysisReport( + root: rootNode, + largeFiles: Array(sortedLargeFiles), + issues: issues, + visitedItemCount: visitedItemCount, + durationSeconds: Date().timeIntervalSince(startedAt) + ) + } + + private func checkCancellation(_ isCancelled: @Sendable () -> Bool) throws { + if isCancelled() { + throw CancellationError() + } + } + + private func relativePathComponents(of url: URL, beneath rootURL: URL) -> [String] { + let rootComponents = rootURL.standardizedFileURL.pathComponents + let entryComponents = url.standardizedFileURL.pathComponents + guard entryComponents.starts(with: rootComponents) else { + return [] + } + return Array(entryComponents.dropFirst(rootComponents.count)) + } + + private func registerDirectory( + components: [String], + rootURL: URL, + rootAccumulator: NodeAccumulator, + maximumDepth: Int, + maximumNodes: Int, + treeNodeCount: inout Int + ) { + var current = rootAccumulator + var currentURL = rootURL + + for component in components.prefix(maximumDepth) { + currentURL.append(path: component, directoryHint: .isDirectory) + guard let child = ensureDirectory( + name: component, + url: currentURL, + parent: current, + rootAccumulator: rootAccumulator, + maximumNodes: maximumNodes, + treeNodeCount: &treeNodeCount + ) else { + return + } + current = child + } + } + + private func addFileSize( + _ sizeBytes: Int64, + directoryComponents: [String], + rootURL: URL, + rootAccumulator: NodeAccumulator, + maximumDepth: Int, + maximumNodes: Int, + treeNodeCount: inout Int + ) { + rootAccumulator.sizeBytes += sizeBytes + var current = rootAccumulator + var currentURL = rootURL + + for component in directoryComponents.prefix(maximumDepth) { + currentURL.append(path: component, directoryHint: .isDirectory) + guard let child = ensureDirectory( + name: component, + url: currentURL, + parent: current, + rootAccumulator: rootAccumulator, + maximumNodes: maximumNodes, + treeNodeCount: &treeNodeCount + ) else { + current.unmappedSizeBytes += sizeBytes + return + } + child.sizeBytes += sizeBytes + current = child + } + + if directoryComponents.count > maximumDepth { + current.unmappedSizeBytes += sizeBytes + } else { + current.directFileSizeBytes += sizeBytes + } + } + + private func ensureDirectory( + name: String, + url: URL, + parent: NodeAccumulator, + rootAccumulator: NodeAccumulator, + maximumNodes: Int, + treeNodeCount: inout Int + ) -> NodeAccumulator? { + if let existing = parent.children[url.path] { + return existing + } + // Directory enumeration is depth-first. Reserve a bounded set of root + // children even after the deeper-node budget is spent, otherwise one + // early subtree (for example Library) can collapse every later home + // folder into the root's "Other" segment. + let isRootChild = parent === rootAccumulator + let canUseRootReserve = isRootChild && parent.children.count < 512 + guard treeNodeCount < maximumNodes || canUseRootReserve else { + return nil + } + + let child = NodeAccumulator(name: name, path: url.path, isDirectory: true) + parent.children[url.path] = child + treeNodeCount += 1 + return child + } + + private func finalize( + _ accumulator: NodeAccumulator, + maximumChildren: Int, + depth: Int + ) -> DiskAnalysisNode { + var children = accumulator.children.values + .filter { $0.sizeBytes > 0 } + .sorted { + if $0.sizeBytes == $1.sizeBytes { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return $0.sizeBytes > $1.sizeBytes + } + .map { finalize($0, maximumChildren: maximumChildren, depth: depth + 1) } + + if accumulator.directFileSizeBytes > 0 { + children.append(DiskAnalysisNode( + id: "\(accumulator.path ?? accumulator.name)#files", + name: "Files", + path: accumulator.path, + sizeBytes: accumulator.directFileSizeBytes, + isDirectory: false, + isAggregate: true + )) + } + + if accumulator.unmappedSizeBytes > 0 { + children.append(DiskAnalysisNode( + id: "\(accumulator.path ?? accumulator.name)#unmapped", + name: "Other", + path: nil, + sizeBytes: accumulator.unmappedSizeBytes, + isDirectory: false, + isAggregate: true + )) + } + + children.sort { + if $0.sizeBytes == $1.sizeBytes { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return $0.sizeBytes > $1.sizeBytes + } + + if children.count > maximumChildren { + let retainedCount = max(1, maximumChildren - 1) + let retained = Array(children.prefix(retainedCount)) + let remainingSize = children.dropFirst(retainedCount).reduce(Int64(0)) { $0 + $1.sizeBytes } + children = retained + [DiskAnalysisNode( + id: "\(accumulator.path ?? accumulator.name)#other-\(depth)", + name: "Other", + path: nil, + sizeBytes: remainingSize, + isDirectory: false, + isAggregate: true + )] + } + + return DiskAnalysisNode( + id: accumulator.path ?? accumulator.name, + name: accumulator.name, + path: accumulator.path, + sizeBytes: accumulator.sizeBytes, + isDirectory: accumulator.isDirectory, + children: children + ) + } + + private func allocatedSize(from values: URLResourceValues) -> Int64 { + if let totalFileAllocatedSize = values.totalFileAllocatedSize { + return Int64(totalFileAllocatedSize) + } + if let fileAllocatedSize = values.fileAllocatedSize { + return Int64(fileAllocatedSize) + } + if let fileSize = values.fileSize { + return Int64(fileSize) + } + return 0 + } +} + +private final class NodeAccumulator { + let name: String + let path: String? + let isDirectory: Bool + var sizeBytes: Int64 = 0 + var directFileSizeBytes: Int64 = 0 + var unmappedSizeBytes: Int64 = 0 + var children: [String: NodeAccumulator] = [:] + + init(name: String, path: String?, isDirectory: Bool) { + self.name = name + self.path = path + self.isDirectory = isDirectory + } +} diff --git a/CleanMacCore/Sources/CleanMacCore/DuplicateCleanup.swift b/CleanMacCore/Sources/CleanMacCore/DuplicateCleanup.swift new file mode 100644 index 0000000..a81ebee --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/DuplicateCleanup.swift @@ -0,0 +1,286 @@ +import Darwin +import Foundation + +public enum DuplicateCleanupRejectionReason: String, Sendable { + case unknownSelection + case protectedOriginal + case missingOriginal + case missingCopy + case outsideSelectedRoot + case symbolicLink + case changedSinceScan +} + +public struct DuplicateCleanupRejectedItem: Identifiable, Sendable { + public let id: String + public let path: String + public let reason: DuplicateCleanupRejectionReason + public let message: String + + public init(path: String, reason: DuplicateCleanupRejectionReason, message: String) { + self.id = "\(reason.rawValue):\(path)" + self.path = path + self.reason = reason + self.message = message + } +} + +public struct DuplicateCleanupPlanItem: Identifiable, Sendable { + public let id: String + public let groupID: String + public let copy: DuplicateFile + public let protectedOriginal: DuplicateFile + + init(groupID: String, copy: DuplicateFile, protectedOriginal: DuplicateFile) { + self.id = copy.id + self.groupID = groupID + self.copy = copy + self.protectedOriginal = protectedOriginal + } +} + +public struct DuplicateCleanupPlan: Sendable { + public let rootPath: String + public let items: [DuplicateCleanupPlanItem] + public let rejectedItems: [DuplicateCleanupRejectedItem] + + init( + rootPath: String, + items: [DuplicateCleanupPlanItem], + rejectedItems: [DuplicateCleanupRejectedItem] + ) { + self.rootPath = rootPath + self.items = items + self.rejectedItems = rejectedItems + } +} + +public struct DuplicateCleanupPlanner { + private let fileManager: FileManager + private let rootURL: URL + + public init(root: URL, fileManager: FileManager = .default) { + self.fileManager = fileManager + self.rootURL = root.resolvingSymlinksInPath().standardizedFileURL + } + + public func plan( + groups: [DuplicateGroup], + selectedCopyIDs: Set + ) -> DuplicateCleanupPlan { + let knownCopies = Dictionary(uniqueKeysWithValues: groups.flatMap { group in + group.copies.map { ($0.id, (group, $0)) } + }) + let originalIDs = Set(groups.map(\.original.id)) + var accepted: [DuplicateCleanupPlanItem] = [] + var rejected: [DuplicateCleanupRejectedItem] = [] + + for selectedID in selectedCopyIDs.sorted() { + if originalIDs.contains(selectedID) { + rejected.append(rejection( + path: selectedID, + reason: .protectedOriginal, + message: "The protected original can never be selected." + )) + continue + } + guard let (group, copy) = knownCopies[selectedID] else { + rejected.append(rejection( + path: selectedID, + reason: .unknownSelection, + message: "The selected path is not a scanned duplicate copy." + )) + continue + } + guard validateOriginal(group.original) else { + rejected.append(rejection( + path: copy.path, + reason: .missingOriginal, + message: "The protected original is missing or changed." + )) + continue + } + guard validateCopy(copy) else { + rejected.append(rejection( + path: copy.path, + reason: rejectionReason(for: copy), + message: "The duplicate copy is unavailable, changed, or outside the selected root." + )) + continue + } + accepted.append(DuplicateCleanupPlanItem( + groupID: group.id, + copy: copy, + protectedOriginal: group.original + )) + } + + return DuplicateCleanupPlan( + rootPath: rootURL.path, + items: accepted, + rejectedItems: rejected + ) + } + + private func validateOriginal(_ file: DuplicateFile) -> Bool { + validate(file) + } + + private func validateCopy(_ file: DuplicateFile) -> Bool { + validate(file) + } + + private func validate(_ file: DuplicateFile) -> Bool { + let url = URL(fileURLWithPath: file.path) + guard fileManager.fileExists(atPath: url.path), + isInsideRoot(url), + !isSymbolicLink(url), + metadataMatches(file, at: url) else { + return false + } + return true + } + + private func rejectionReason(for file: DuplicateFile) -> DuplicateCleanupRejectionReason { + let url = URL(fileURLWithPath: file.path) + if !fileManager.fileExists(atPath: url.path) { + return .missingCopy + } + if !isInsideRoot(url) { + return .outsideSelectedRoot + } + if isSymbolicLink(url) { + return .symbolicLink + } + return .changedSinceScan + } + + private func isInsideRoot(_ url: URL) -> Bool { + let canonicalRoot = rootURL.canonicalPath + let canonicalPath = url.canonicalPath + return canonicalPath != canonicalRoot && canonicalPath.hasPrefix(canonicalRoot + "/") + } + + private func isSymbolicLink(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } + + private func metadataMatches(_ file: DuplicateFile, at url: URL) -> Bool { + guard let values = try? url.resourceValues(forKeys: [ + .isRegularFileKey, + .fileSizeKey, + .contentModificationDateKey + ]), + values.isRegularFile == true, + Int64(values.fileSize ?? -1) == file.sizeBytes, + file.modifiedAt == nil || values.contentModificationDate == file.modifiedAt else { + return false + } + + var fileInfo = stat() + guard url.path.withCString({ lstat($0, &fileInfo) }) == 0 else { + return false + } + return UInt64(bitPattern: Int64(fileInfo.st_dev)) == file.deviceID + && UInt64(fileInfo.st_ino) == file.inode + } + + private func rejection( + path: String, + reason: DuplicateCleanupRejectionReason, + message: String + ) -> DuplicateCleanupRejectedItem { + DuplicateCleanupRejectedItem(path: path, reason: reason, message: message) + } +} + +public struct DuplicateMovedItem: Identifiable, Sendable { + public let id: String + public let item: DuplicateCleanupPlanItem + public let trashedPath: String? + + public init(item: DuplicateCleanupPlanItem, trashedPath: String?) { + self.id = item.id + self.item = item + self.trashedPath = trashedPath + } +} + +public struct DuplicateCleanupFailedItem: Identifiable, Sendable { + public let id: String + public let item: DuplicateCleanupPlanItem + public let message: String + + public init(item: DuplicateCleanupPlanItem, message: String) { + self.id = item.id + self.item = item + self.message = message + } +} + +public struct DuplicateCleanupReport: Sendable { + public let completedAt: Date + public let movedItems: [DuplicateMovedItem] + public let failedItems: [DuplicateCleanupFailedItem] + public let rejectedItems: [DuplicateCleanupRejectedItem] + + public init( + completedAt: Date, + movedItems: [DuplicateMovedItem], + failedItems: [DuplicateCleanupFailedItem], + rejectedItems: [DuplicateCleanupRejectedItem] + ) { + self.completedAt = completedAt + self.movedItems = movedItems + self.failedItems = failedItems + self.rejectedItems = rejectedItems + } +} + +public struct DuplicateCleanupExecutor { + public typealias TrashHandler = (URL) throws -> URL? + + private let fileManager: FileManager + private let trashHandler: TrashHandler + + public init(fileManager: FileManager = .default, trashHandler: TrashHandler? = nil) { + self.fileManager = fileManager + if let trashHandler { + self.trashHandler = trashHandler + } else { + self.trashHandler = { url in + var resultingURL: NSURL? + try fileManager.trashItem(at: url, resultingItemURL: &resultingURL) + return resultingURL as URL? + } + } + } + + public func execute(plan: DuplicateCleanupPlan) -> DuplicateCleanupReport { + var movedItems: [DuplicateMovedItem] = [] + var failedItems: [DuplicateCleanupFailedItem] = [] + + for item in plan.items { + guard fileManager.fileExists(atPath: item.protectedOriginal.path) else { + failedItems.append(DuplicateCleanupFailedItem( + item: item, + message: "The protected original disappeared before cleanup." + )) + continue + } + do { + let trashedURL = try trashHandler(URL(fileURLWithPath: item.copy.path)) + movedItems.append(DuplicateMovedItem(item: item, trashedPath: trashedURL?.path)) + } catch { + failedItems.append(DuplicateCleanupFailedItem(item: item, message: error.localizedDescription)) + } + } + + return DuplicateCleanupReport( + completedAt: Date(), + movedItems: movedItems, + failedItems: failedItems, + rejectedItems: plan.rejectedItems + ) + } +} diff --git a/CleanMacCore/Sources/CleanMacCore/DuplicateFinder.swift b/CleanMacCore/Sources/CleanMacCore/DuplicateFinder.swift new file mode 100644 index 0000000..a94aac1 --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/DuplicateFinder.swift @@ -0,0 +1,583 @@ +import CryptoKit +import Darwin +import Foundation + +public enum DuplicateScanMode: String, Sendable { + case standard + case includeLargeFiles +} + +public struct DuplicateScanOptions: Equatable, Sendable { + public var mode: DuplicateScanMode + public var partialHashByteCount: Int + public var fullHashChunkByteCount: Int + public var largeFileThresholdBytes: Int64 + public var maxConcurrentHashes: Int + public var maxFiles: Int + + public init( + mode: DuplicateScanMode = .standard, + partialHashByteCount: Int = 128 * 1024, + fullHashChunkByteCount: Int = 1024 * 1024, + largeFileThresholdBytes: Int64 = 500 * 1024 * 1024, + maxConcurrentHashes: Int = 2, + maxFiles: Int = 250_000 + ) { + self.mode = mode + self.partialHashByteCount = max(1, partialHashByteCount) + self.fullHashChunkByteCount = max(1, fullHashChunkByteCount) + self.largeFileThresholdBytes = max(1, largeFileThresholdBytes) + self.maxConcurrentHashes = min(max(1, maxConcurrentHashes), 8) + self.maxFiles = max(1, maxFiles) + } +} + +public enum DuplicateScanPhase: String, Sendable { + case enumerating + case groupingBySize + case partialHashing + case fullHashing + case finalizing + case completed +} + +public struct DuplicateScanProgress: Equatable, Sendable { + public let phase: DuplicateScanPhase + public let currentPath: String? + public let discoveredFileCount: Int + public let candidateFileCount: Int + public let hashedFileCount: Int + + public init( + phase: DuplicateScanPhase, + currentPath: String?, + discoveredFileCount: Int, + candidateFileCount: Int, + hashedFileCount: Int + ) { + self.phase = phase + self.currentPath = currentPath + self.discoveredFileCount = max(0, discoveredFileCount) + self.candidateFileCount = max(0, candidateFileCount) + self.hashedFileCount = max(0, hashedFileCount) + } +} + +public struct DuplicateFile: Identifiable, Hashable, Sendable { + public let id: String + public let path: String + public let name: String + public let sizeBytes: Int64 + public let createdAt: Date? + public let modifiedAt: Date? + public let deviceID: UInt64 + public let inode: UInt64 + + public init( + path: String, + name: String, + sizeBytes: Int64, + createdAt: Date?, + modifiedAt: Date?, + deviceID: UInt64, + inode: UInt64 + ) { + self.id = path + self.path = path + self.name = name + self.sizeBytes = sizeBytes + self.createdAt = createdAt + self.modifiedAt = modifiedAt + self.deviceID = deviceID + self.inode = inode + } +} + +public struct DuplicateGroup: Identifiable, Hashable, Sendable { + public let id: String + public let fullHash: String + public let original: DuplicateFile + public let copies: [DuplicateFile] + + public init(fullHash: String, original: DuplicateFile, copies: [DuplicateFile]) { + self.id = "\(original.sizeBytes):\(fullHash)" + self.fullHash = fullHash + self.original = original + self.copies = copies + } + + public var allFiles: [DuplicateFile] { + [original] + copies + } + + public var reclaimableBytes: Int64 { + let result = original.sizeBytes.multipliedReportingOverflow(by: Int64(copies.count)) + return result.overflow ? Int64.max : result.partialValue + } +} + +public struct DuplicateScanIssue: Equatable, Sendable { + public let path: String + public let message: String + + public init(path: String, message: String) { + self.path = path + self.message = message + } +} + +public struct DuplicateScanReport: Equatable, Sendable { + public let rootPath: String + public let scannedAt: Date + public let durationSeconds: TimeInterval + public let discoveredFileCount: Int + public let groups: [DuplicateGroup] + public let deferredLargeCandidates: [DuplicateFile] + public let issues: [DuplicateScanIssue] + + public init( + rootPath: String, + scannedAt: Date, + durationSeconds: TimeInterval, + discoveredFileCount: Int, + groups: [DuplicateGroup], + deferredLargeCandidates: [DuplicateFile], + issues: [DuplicateScanIssue] + ) { + self.rootPath = rootPath + self.scannedAt = scannedAt + self.durationSeconds = durationSeconds + self.discoveredFileCount = discoveredFileCount + self.groups = groups + self.deferredLargeCandidates = deferredLargeCandidates + self.issues = issues + } + + public var copyCount: Int { + groups.reduce(0) { $0 + $1.copies.count } + } + + public var reclaimableBytes: Int64 { + groups.reduce(0) { total, group in + let result = total.addingReportingOverflow(group.reclaimableBytes) + return result.overflow ? Int64.max : result.partialValue + } + } +} + +public enum DuplicateFinderError: Error, Equatable, Sendable { + case rootIsUnavailable + case rootIsNotDirectory +} + +public struct DuplicateFinder { + private let fileManager: FileManager + private let resourceKeys: Set = [ + .isDirectoryKey, + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + .creationDateKey, + .contentModificationDateKey + ] + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + public func scan( + root: URL, + options: DuplicateScanOptions = DuplicateScanOptions(), + progress: (@Sendable (DuplicateScanProgress) -> Void)? = nil + ) async throws -> DuplicateScanReport { + let startedAt = Date() + let rootURL = root.resolvingSymlinksInPath().standardizedFileURL + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: rootURL.path, isDirectory: &isDirectory) else { + throw DuplicateFinderError.rootIsUnavailable + } + guard isDirectory.boolValue else { + throw DuplicateFinderError.rootIsNotDirectory + } + + var issues: [DuplicateScanIssue] = [] + let files = try enumerateFiles( + rootURL: rootURL, + maxFiles: options.maxFiles, + issues: &issues, + progress: progress + ) + + try Task.checkCancellation() + progress?(DuplicateScanProgress( + phase: .groupingBySize, + currentPath: nil, + discoveredFileCount: files.count, + candidateFileCount: 0, + hashedFileCount: 0 + )) + + let sizeGroups = Dictionary(grouping: files, by: \.sizeBytes) + .values + .filter { $0.count > 1 } + .map(Self.removingHardLinks) + .filter { $0.count > 1 } + .sorted { ($0.first?.sizeBytes ?? 0) > ($1.first?.sizeBytes ?? 0) } + + let largeGroups = sizeGroups.filter { ($0.first?.sizeBytes ?? 0) > options.largeFileThresholdBytes } + let deferredLargeCandidates = options.mode == .standard + ? largeGroups.flatMap { $0 }.sorted(by: Self.largerFileFirst) + : [] + let activeGroups = options.mode == .standard + ? sizeGroups.filter { ($0.first?.sizeBytes ?? 0) <= options.largeFileThresholdBytes } + : sizeGroups + let partialCandidates = activeGroups.flatMap { $0 } + + let partialOutcomes = try await hashFiles( + partialCandidates, + stage: .partial, + options: options, + phase: .partialHashing, + discoveredFileCount: files.count, + progress: progress + ) + issues.append(contentsOf: partialOutcomes.compactMap(\.issue)) + + let partialGroups = Dictionary( + grouping: partialOutcomes.compactMap(\.success), + by: { "\($0.file.sizeBytes):\($0.hash)" } + ) + .values + .filter { $0.count > 1 } + let fullCandidates = partialGroups.flatMap { $0.map(\.file) } + + let fullOutcomes = try await hashFiles( + fullCandidates, + stage: .full, + options: options, + phase: .fullHashing, + discoveredFileCount: files.count, + progress: progress + ) + issues.append(contentsOf: fullOutcomes.compactMap(\.issue)) + + try Task.checkCancellation() + progress?(DuplicateScanProgress( + phase: .finalizing, + currentPath: nil, + discoveredFileCount: files.count, + candidateFileCount: fullCandidates.count, + hashedFileCount: fullOutcomes.count + )) + + let fullGroups = Dictionary( + grouping: fullOutcomes.compactMap(\.success), + by: { "\($0.file.sizeBytes):\($0.hash)" } + ) + let groups = fullGroups.values.compactMap { matches -> DuplicateGroup? in + let uniqueFiles = Self.removingHardLinks(matches.map(\.file)) + guard uniqueFiles.count > 1, let hash = matches.first?.hash else { + return nil + } + let original = Self.chooseOriginal(from: uniqueFiles) + let copies = uniqueFiles + .filter { $0.id != original.id } + .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + guard !copies.isEmpty else { + return nil + } + return DuplicateGroup(fullHash: hash, original: original, copies: copies) + } + .sorted { + if $0.reclaimableBytes == $1.reclaimableBytes { + return $0.original.path.localizedStandardCompare($1.original.path) == .orderedAscending + } + return $0.reclaimableBytes > $1.reclaimableBytes + } + + progress?(DuplicateScanProgress( + phase: .completed, + currentPath: nil, + discoveredFileCount: files.count, + candidateFileCount: fullCandidates.count, + hashedFileCount: fullOutcomes.count + )) + + return DuplicateScanReport( + rootPath: rootURL.path, + scannedAt: startedAt, + durationSeconds: Date().timeIntervalSince(startedAt), + discoveredFileCount: files.count, + groups: groups, + deferredLargeCandidates: deferredLargeCandidates, + issues: issues + ) + } + + private func enumerateFiles( + rootURL: URL, + maxFiles: Int, + issues: inout [DuplicateScanIssue], + progress: (@Sendable (DuplicateScanProgress) -> Void)? + ) throws -> [DuplicateFile] { + var enumerationIssues: [DuplicateScanIssue] = [] + guard let enumerator = fileManager.enumerator( + at: rootURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { url, error in + enumerationIssues.append(DuplicateScanIssue(path: url.path, message: error.localizedDescription)) + return true + } + ) else { + throw DuplicateFinderError.rootIsUnavailable + } + + var files: [DuplicateFile] = [] + for case let url as URL in enumerator { + try Task.checkCancellation() + if files.count >= maxFiles { + enumerationIssues.append(DuplicateScanIssue( + path: rootURL.path, + message: "The duplicate scan reached its file-count safety limit." + )) + break + } + + guard let file = duplicateFile(at: url) else { + continue + } + files.append(file) + if files.count == 1 || files.count.isMultiple(of: 250) { + progress?(DuplicateScanProgress( + phase: .enumerating, + currentPath: file.path, + discoveredFileCount: files.count, + candidateFileCount: 0, + hashedFileCount: 0 + )) + } + } + issues.append(contentsOf: enumerationIssues) + return files + } + + private func duplicateFile(at url: URL) -> DuplicateFile? { + guard let values = try? url.resourceValues(forKeys: resourceKeys), + values.isRegularFile == true, + values.isSymbolicLink != true, + let fileSize = values.fileSize, + fileSize > 0 else { + return nil + } + + var fileInfo = stat() + guard url.path.withCString({ lstat($0, &fileInfo) }) == 0 else { + return nil + } + + return DuplicateFile( + path: url.standardizedFileURL.path, + name: url.lastPathComponent, + sizeBytes: Int64(fileSize), + createdAt: values.creationDate, + modifiedAt: values.contentModificationDate, + deviceID: UInt64(bitPattern: Int64(fileInfo.st_dev)), + inode: UInt64(fileInfo.st_ino) + ) + } + + private enum HashStage: Sendable { + case partial + case full + } + + private struct HashMatch: Sendable { + let file: DuplicateFile + let hash: String + } + + private struct HashOutcome: Sendable { + let success: HashMatch? + let issue: DuplicateScanIssue? + } + + private func hashFiles( + _ files: [DuplicateFile], + stage: HashStage, + options: DuplicateScanOptions, + phase: DuplicateScanPhase, + discoveredFileCount: Int, + progress: (@Sendable (DuplicateScanProgress) -> Void)? + ) async throws -> [HashOutcome] { + guard !files.isEmpty else { + progress?(DuplicateScanProgress( + phase: phase, + currentPath: nil, + discoveredFileCount: discoveredFileCount, + candidateFileCount: 0, + hashedFileCount: 0 + )) + return [] + } + + return try await withThrowingTaskGroup(of: HashOutcome.self) { group in + var iterator = files.makeIterator() + var submittedCount = 0 + var completedCount = 0 + var outcomes: [HashOutcome] = [] + + while submittedCount < options.maxConcurrentHashes, let file = iterator.next() { + submittedCount += 1 + group.addTask { + try Self.hashOutcome(file: file, stage: stage, options: options) + } + } + + while let outcome = try await group.next() { + try Task.checkCancellation() + completedCount += 1 + outcomes.append(outcome) + progress?(DuplicateScanProgress( + phase: phase, + currentPath: outcome.success?.file.path ?? outcome.issue?.path, + discoveredFileCount: discoveredFileCount, + candidateFileCount: files.count, + hashedFileCount: completedCount + )) + + if let file = iterator.next() { + group.addTask { + try Self.hashOutcome(file: file, stage: stage, options: options) + } + } + } + return outcomes + } + } + + private static func hashOutcome( + file: DuplicateFile, + stage: HashStage, + options: DuplicateScanOptions + ) throws -> HashOutcome { + do { + let hash = try hash(file: file, stage: stage, options: options) + return HashOutcome(success: HashMatch(file: file, hash: hash), issue: nil) + } catch is CancellationError { + throw CancellationError() + } catch { + return HashOutcome( + success: nil, + issue: DuplicateScanIssue(path: file.path, message: error.localizedDescription) + ) + } + } + + private static func hash( + file: DuplicateFile, + stage: HashStage, + options: DuplicateScanOptions + ) throws -> String { + let url = URL(fileURLWithPath: file.path) + let before = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard before.isRegularFile == true, Int64(before.fileSize ?? -1) == file.sizeBytes else { + throw CocoaError(.fileReadUnknown) + } + + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let byteLimit: Int64? = stage == .partial ? Int64(options.partialHashByteCount) : nil + var remaining = byteLimit + var totalRead: Int64 = 0 + var hasher = SHA256() + + while remaining == nil || (remaining ?? 0) > 0 { + try Task.checkCancellation() + let requestSize = Int(min( + Int64(options.fullHashChunkByteCount), + remaining ?? Int64(options.fullHashChunkByteCount) + )) + guard requestSize > 0, + let data = try handle.read(upToCount: requestSize), + !data.isEmpty else { + break + } + hasher.update(data: data) + totalRead += Int64(data.count) + if remaining != nil { + remaining = max(0, (remaining ?? 0) - Int64(data.count)) + } + } + + if stage == .full { + guard totalRead == file.sizeBytes else { + throw CocoaError(.fileReadUnknown) + } + let after = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + guard Int64(after.fileSize ?? -1) == file.sizeBytes, + file.modifiedAt == nil || after.contentModificationDate == file.modifiedAt else { + throw CocoaError(.fileReadUnknown) + } + } + + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private struct FileIdentity: Hashable { + let deviceID: UInt64 + let inode: UInt64 + } + + private static func removingHardLinks(_ files: [DuplicateFile]) -> [DuplicateFile] { + var seen: Set = [] + return files + .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + .filter { file in + guard file.inode != 0 else { + return true + } + return seen.insert(FileIdentity(deviceID: file.deviceID, inode: file.inode)).inserted + } + } + + public static func chooseOriginal(from files: [DuplicateFile]) -> DuplicateFile { + precondition(!files.isEmpty) + return files.min(by: isMoreOriginal) ?? files[0] + } + + private static func isMoreOriginal(_ left: DuplicateFile, _ right: DuplicateFile) -> Bool { + let leftLooksLikeCopy = isLikelyCopyPath(left.path) + let rightLooksLikeCopy = isLikelyCopyPath(right.path) + if leftLooksLikeCopy != rightLooksLikeCopy { + return !leftLooksLikeCopy + } + + let leftDepth = URL(fileURLWithPath: left.path).pathComponents.count + let rightDepth = URL(fileURLWithPath: right.path).pathComponents.count + if leftDepth != rightDepth { + return leftDepth < rightDepth + } + + let leftDate = left.createdAt ?? left.modifiedAt ?? .distantFuture + let rightDate = right.createdAt ?? right.modifiedAt ?? .distantFuture + if leftDate != rightDate { + return leftDate < rightDate + } + return left.path.localizedStandardCompare(right.path) == .orderedAscending + } + + private static func isLikelyCopyPath(_ path: String) -> Bool { + let normalized = path.lowercased() + let markers = ["/backup", " copy", "(copy", " (1)", " (2)", " (3)", " 2."] + return markers.contains { normalized.contains($0) } + } + + private static func largerFileFirst(_ left: DuplicateFile, _ right: DuplicateFile) -> Bool { + if left.sizeBytes == right.sizeBytes { + return left.path.localizedStandardCompare(right.path) == .orderedAscending + } + return left.sizeBytes > right.sizeBytes + } +} diff --git a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift index 25be6a7..fb99a71 100644 --- a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift +++ b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift @@ -111,6 +111,189 @@ final class CleanMacCoreTests: XCTestCase { XCTAssertEqual(itemsByCategory[.downloadedInstallers]?.first?.reasons, [.installerArchive]) } + func testDeveloperCacheRootsAreExactAndExcludeUserData() { + let home = URL(fileURLWithPath: "/Users/Test") + let resolver = CleanupRootResolver( + homeDirectory: home, + temporaryDirectory: URL(fileURLWithPath: "/tmp/Test") + ) + let paths = [ + CleanupCategory.developerPackageCaches, + .developerIDECaches, + .developerAITemporaryFiles + ].flatMap { resolver.rootURLs(for: $0).map(\.path) } + + let requiredSuffixes = [ + "/Library/Caches/Homebrew", + "/Library/Caches/pip", + "/.cargo/registry/cache", + "/.cargo/registry/src", + "/.gradle/caches", + "/Application Support/Cursor/Cache", + "/Application Support/Code/Cache", + "/.codex/.tmp", + "/.codex/tmp", + "/.codex/cache", + "/.claude/cache", + "/.claude/paste-cache" + ] + for suffix in requiredSuffixes { + XCTAssertTrue(paths.contains { $0.hasSuffix(suffix) }, "Missing exact root: \(suffix)") + } + + let forbiddenFragments = [ + "/Cursor/User", + "/Code/User", + "/extensions", + "/workspaceStorage", + "/Backups", + "/.codex/sessions", + "/.codex/archived_sessions", + "/.codex/shell_snapshots", + "/.codex/memories", + "/.claude/projects", + "/.claude/history", + "/.claude/shell-snapshots" + ] + for path in paths { + for forbidden in forbiddenFragments { + XCTAssertFalse(path.contains(forbidden), "Developer cleanup must not target \(forbidden): \(path)") + } + } + } + + func testScannerFindsDeveloperCachesButNotNeighboringUserData() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let includedRoots = [ + "Library/Caches/Homebrew", + "Library/Caches/pip", + ".cargo/registry/cache", + ".cargo/registry/src", + ".gradle/caches", + "Library/Application Support/Cursor/Cache", + "Library/Application Support/Code/GPUCache", + ".codex/.tmp", + ".codex/tmp", + ".codex/cache", + ".claude/cache", + ".claude/paste-cache" + ] + var expectedCandidatePaths: Set = [] + for (index, relativeRoot) in includedRoots.enumerated() { + let candidate = home.appending(path: relativeRoot, directoryHint: .isDirectory) + .appending(path: "candidate-\(index)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true) + try writeBytes(count: 16, to: candidate.appending(path: "cache.bin")) + expectedCandidatePaths.insert(canonicalPath(candidate.path)) + } + + let forbiddenFiles = [ + ".codex/sessions/session.jsonl", + ".codex/memories/MEMORY.md", + ".claude/projects/project.json", + "Library/Application Support/Cursor/User/settings.json", + "Library/Application Support/Code/extensions/extension.bin" + ].map { home.appending(path: $0) } + for file in forbiddenFiles { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeBytes(count: 16, to: file) + } + + let scanner = CleanupScanner(homeDirectory: home, temporaryDirectory: root.appending(path: "Temp")) + let report = scanner.scan( + categories: [.developerPackageCaches, .developerIDECaches, .developerAITemporaryFiles], + options: CleanupScanOptions(maxItemsPerCategory: 40, maxDescendantsPerItem: 50) + ) + let scannedPaths = Set(report.items.map { canonicalPath($0.path) }) + + XCTAssertTrue(expectedCandidatePaths.isSubset(of: scannedPaths)) + XCTAssertTrue(report.items.allSatisfy { $0.risk == .safe }) + XCTAssertFalse(report.items.contains { item in + forbiddenFiles.contains { canonicalPath($0.path) == canonicalPath(item.path) } + }) + } + + func testXcodeDeveloperStorageUsesReviewAndAgeRules() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let deviceSupport = home.appending(path: "Library/Developer/Xcode/iOS DeviceSupport/17.0", directoryHint: .isDirectory) + let previews = home.appending(path: "Library/Developer/Xcode/UserData/Previews/Simulator Devices", directoryHint: .isDirectory) + let oldDevice = home.appending(path: "Library/Developer/CoreSimulator/Devices/OLD-DEVICE", directoryHint: .isDirectory) + let recentDevice = home.appending(path: "Library/Developer/CoreSimulator/Devices/RECENT-DEVICE", directoryHint: .isDirectory) + let oldRuntime = home.appending(path: "Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", directoryHint: .isDirectory) + let archive = home.appending(path: "Library/Developer/Xcode/Archives/2025-01-01/My App.xcarchive", directoryHint: .isDirectory) + + for directory in [deviceSupport, previews, oldDevice, recentDevice, oldRuntime, archive] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try writeBytes(count: 16, to: directory.appending(path: "data.bin")) + } + try setModificationDate(Date().addingTimeInterval(-200 * 24 * 60 * 60), for: oldDevice) + try setModificationDate(Date().addingTimeInterval(-10 * 24 * 60 * 60), for: recentDevice) + try setModificationDate(Date().addingTimeInterval(-220 * 24 * 60 * 60), for: oldRuntime) + + let scanner = CleanupScanner(homeDirectory: home, temporaryDirectory: root.appending(path: "Temp")) + let report = scanner.scan( + categories: [.xcodeDeviceSupport, .xcodePreviews, .xcodeSimulatorData, .xcodeArchives], + options: CleanupScanOptions( + maxItemsPerCategory: 20, + maxDescendantsPerItem: 50, + staleDeveloperDataAge: 180 * 24 * 60 * 60 + ) + ) + let itemsByPath = Dictionary(uniqueKeysWithValues: report.items.map { (canonicalPath($0.path), $0) }) + + XCTAssertEqual(itemsByPath[canonicalPath(deviceSupport.path)]?.risk, .review) + XCTAssertEqual(itemsByPath[canonicalPath(previews.path)]?.risk, .safe) + XCTAssertEqual(itemsByPath[canonicalPath(oldDevice.path)]?.reasons, [.staleSimulatorData]) + XCTAssertEqual(itemsByPath[canonicalPath(oldRuntime.path)]?.reasons, [.staleSimulatorData]) + XCTAssertNil(itemsByPath[canonicalPath(recentDevice.path)]) + XCTAssertEqual(itemsByPath[canonicalPath(archive.path)]?.risk, .review) + XCTAssertEqual(itemsByPath[canonicalPath(archive.path)]?.reasons, [.xcodeArchive]) + XCTAssertFalse(report.items.contains { $0.category == .xcodeArchives && $0.risk == .safe }) + } + + func testDeveloperPlannerRejectsSensitiveNeighborsAndBroadXcodePaths() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let aiCache = home.appending(path: ".codex/tmp/job", directoryHint: .isDirectory) + let aiSession = home.appending(path: ".codex/sessions/session.jsonl") + let archive = home.appending(path: "Library/Developer/Xcode/Archives/2025-01-01/App.xcarchive", directoryHint: .isDirectory) + let archiveDay = archive.deletingLastPathComponent() + let simulatorDevice = home.appending(path: "Library/Developer/CoreSimulator/Devices/DEVICE", directoryHint: .isDirectory) + let simulatorNestedData = simulatorDevice.appending(path: "data/private", directoryHint: .isDirectory) + + for directory in [aiCache, archive, simulatorNestedData] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + try FileManager.default.createDirectory(at: aiSession.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeBytes(count: 8, to: aiSession) + + let items = [ + makeScanItem(category: .developerAITemporaryFiles, path: aiCache.path, isDirectory: true), + makeScanItem(category: .developerAITemporaryFiles, path: aiSession.path), + makeScanItem(category: .xcodeArchives, path: archive.path, isDirectory: true), + makeScanItem(category: .xcodeArchives, path: archiveDay.path, isDirectory: true), + makeScanItem(category: .xcodeSimulatorData, path: simulatorDevice.path, isDirectory: true), + makeScanItem(category: .xcodeSimulatorData, path: simulatorNestedData.path, isDirectory: true) + ] + let plan = CleanupPlanner(homeDirectory: home, temporaryDirectory: root.appending(path: "Temp")) + .plan(for: items) + + XCTAssertEqual(Set(plan.items.map(\.originalPath)), Set([ + canonicalPath(aiCache.path), + canonicalPath(archive.path), + canonicalPath(simulatorDevice.path) + ])) + XCTAssertEqual(plan.rejectedItems.count, 3) + } + func testScannerReportsCategoryProgress() throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -914,7 +1097,15 @@ final class CleanMacCoreTests: XCTestCase { } private var reviewCategories: Set { - [.downloads, .downloadedInstallers, .trash, .xcodeDerivedData] + [ + .downloads, + .downloadedInstallers, + .trash, + .xcodeDerivedData, + .xcodeDeviceSupport, + .xcodeSimulatorData, + .xcodeArchives + ] } } diff --git a/CleanMacCore/Tests/CleanMacCoreTests/DiskAnalyzerTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/DiskAnalyzerTests.swift new file mode 100644 index 0000000..5ebecd8 --- /dev/null +++ b/CleanMacCore/Tests/CleanMacCoreTests/DiskAnalyzerTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import CleanMacCore + +final class DiskAnalyzerTests: XCTestCase { + func testReadOnlyScanBuildsTreeAndFindsLargeFiles() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let documents = root.appending(path: "Documents", directoryHint: .isDirectory) + let media = root.appending(path: "Media", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) + + let largeVideo = media.appending(path: "Movie.mov") + let smallDocument = documents.appending(path: "Notes.txt") + try write(size: 2 * 1024 * 1024, to: largeVideo) + try write(size: 128 * 1024, to: smallDocument) + + let progressRecorder = DiskProgressRecorder() + let report = try DiskAnalyzer().scan( + root: root, + options: DiskAnalysisOptions( + minimumLargeFileSizeBytes: 1024 * 1024, + maximumLargeFiles: 20, + maximumTreeDepth: 4, + maximumChildrenPerNode: 8, + maximumTreeNodes: 100, + progressInterval: 1 + ), + progress: progressRecorder.append + ) + let progressEvents = progressRecorder.events + + XCTAssertGreaterThan(report.root.sizeBytes, 0) + XCTAssertEqual(Set(report.root.children.filter(\.isDirectory).map(\.name)), ["Documents", "Media"]) + XCTAssertEqual(report.largeFiles.map(\.name), ["Movie.mov"]) + XCTAssertEqual(report.largeFiles.first?.fileType, .video) + XCTAssertEqual(progressEvents.first?.phase, .preparing) + XCTAssertEqual(progressEvents.last?.phase, .completed) + XCTAssertTrue(FileManager.default.fileExists(atPath: largeVideo.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: smallDocument.path)) + } + + func testScanDoesNotFollowSymbolicLinksOutsideRoot() throws { + let container = try makeRoot() + defer { try? FileManager.default.removeItem(at: container) } + + let root = container.appending(path: "Root", directoryHint: .isDirectory) + let outside = container.appending(path: "Outside", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + + let outsideFile = outside.appending(path: "Outside.mov") + try write(size: 2 * 1024 * 1024, to: outsideFile) + try FileManager.default.createSymbolicLink( + at: root.appending(path: "Outside Link", directoryHint: .isDirectory), + withDestinationURL: outside + ) + + let report = try DiskAnalyzer().scan( + root: root, + options: DiskAnalysisOptions(minimumLargeFileSizeBytes: 1024 * 1024) + ) + + XCTAssertTrue(report.largeFiles.isEmpty) + XCTAssertEqual(report.root.sizeBytes, 0) + XCTAssertTrue(FileManager.default.fileExists(atPath: outsideFile.path)) + } + + func testScanCanBeCancelledBeforeEnumeration() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertThrowsError(try DiskAnalyzer().scan(root: root, isCancelled: { true })) { error in + XCTAssertTrue(error is CancellationError) + } + } + + func testTreeBreadthIsBoundedAndRemainderIsAggregated() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + for index in 0..<6 { + let directory = root.appending(path: "Folder\(index)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try write(size: (index + 1) * 32 * 1024, to: directory.appending(path: "file.bin")) + } + + let report = try DiskAnalyzer().scan( + root: root, + options: DiskAnalysisOptions( + minimumLargeFileSizeBytes: 10 * 1024 * 1024, + maximumLargeFiles: 10, + maximumTreeDepth: 3, + maximumChildrenPerNode: 3, + maximumTreeNodes: 100, + progressInterval: 10 + ) + ) + + XCTAssertEqual(report.root.children.count, 3) + XCTAssertTrue(report.root.children.contains(where: \.isAggregate)) + XCTAssertEqual(report.root.children.reduce(0) { $0 + $1.sizeBytes }, report.root.sizeBytes) + } + + func testDeepEarlyFolderDoesNotHideLaterRootFolders() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let early = root.appending(path: "A-Early", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: early, withIntermediateDirectories: true) + for index in 0..<40 { + let nested = early.appending(path: "Nested\(index)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try write(size: 1024, to: nested.appending(path: "small.bin")) + } + + let later = root.appending(path: "Z-Later", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: later, withIntermediateDirectories: true) + try write(size: 1024 * 1024, to: later.appending(path: "large.bin")) + + let report = try DiskAnalyzer().scan( + root: root, + options: DiskAnalysisOptions( + minimumLargeFileSizeBytes: 10 * 1024 * 1024, + maximumLargeFiles: 10, + maximumTreeDepth: 4, + maximumChildrenPerNode: 8, + maximumTreeNodes: 32, + progressInterval: 10 + ) + ) + + XCTAssertTrue(report.root.children.contains { $0.name == "Z-Later" }) + } + + private func makeRoot() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "CleanMac-DiskAnalyzerTests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(size: Int, to url: URL) throws { + try Data(repeating: 0x41, count: size).write(to: url, options: .atomic) + } +} + +private final class DiskProgressRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [DiskAnalysisProgress] = [] + + var events: [DiskAnalysisProgress] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ event: DiskAnalysisProgress) { + lock.lock() + storage.append(event) + lock.unlock() + } +} diff --git a/CleanMacCore/Tests/CleanMacCoreTests/DuplicateFinderTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/DuplicateFinderTests.swift new file mode 100644 index 0000000..169edc8 --- /dev/null +++ b/CleanMacCore/Tests/CleanMacCoreTests/DuplicateFinderTests.swift @@ -0,0 +1,240 @@ +import Foundation +import XCTest +@testable import CleanMacCore + +final class DuplicateFinderTests: XCTestCase { + func testHashConcurrencyIsAlwaysBounded() { + XCTAssertEqual(DuplicateScanOptions(maxConcurrentHashes: 0).maxConcurrentHashes, 1) + XCTAssertEqual(DuplicateScanOptions(maxConcurrentHashes: 2).maxConcurrentHashes, 2) + XCTAssertEqual(DuplicateScanOptions(maxConcurrentHashes: 100).maxConcurrentHashes, 8) + } + + func testProgressivePipelineUsesFullHashAfterMatchingPartialHash() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + try Data("ABCD1111".utf8).write(to: root.appending(path: "a.bin")) + try Data("ABCD1111".utf8).write(to: root.appending(path: "b.bin")) + try Data("ABCD2222".utf8).write(to: root.appending(path: "c.bin")) + try Data("WXYZ3333".utf8).write(to: root.appending(path: "d.bin")) + + let report = try await DuplicateFinder().scan( + root: root, + options: DuplicateScanOptions( + partialHashByteCount: 4, + fullHashChunkByteCount: 3, + maxConcurrentHashes: 2 + ) + ) + + XCTAssertEqual(report.groups.count, 1) + XCTAssertEqual(report.groups[0].original.name, "a.bin") + XCTAssertEqual(report.groups[0].copies.map(\.name), ["b.bin"]) + XCTAssertEqual(report.reclaimableBytes, 8) + XCTAssertFalse(report.groups[0].allFiles.contains { $0.name == "c.bin" }) + XCTAssertFalse(report.groups[0].allFiles.contains { $0.name == "d.bin" }) + } + + func testHardLinksDoNotCreateFalseDuplicateSavings() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let original = root.appending(path: "a.bin") + let hardLink = root.appending(path: "b-hard-link.bin") + let separateCopy = root.appending(path: "c-copy.bin") + let data = Data("identical-content".utf8) + try data.write(to: original) + try FileManager.default.linkItem(at: original, to: hardLink) + try data.write(to: separateCopy) + + let report = try await DuplicateFinder().scan(root: root) + + XCTAssertEqual(report.groups.count, 1) + XCTAssertEqual(report.groups[0].allFiles.count, 2) + XCTAssertEqual(report.groups[0].reclaimableBytes, Int64(data.count)) + XCTAssertFalse(report.groups[0].allFiles.contains { $0.name == "b-hard-link.bin" }) + } + + func testLargeCandidatesAreReportedThenIncludedInSlowMode() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let data = Data(repeating: 7, count: 256) + try data.write(to: root.appending(path: "large-a.bin")) + try data.write(to: root.appending(path: "large-b.bin")) + + let standardReport = try await DuplicateFinder().scan( + root: root, + options: DuplicateScanOptions( + mode: .standard, + partialHashByteCount: 16, + largeFileThresholdBytes: 128, + maxConcurrentHashes: 2 + ) + ) + XCTAssertTrue(standardReport.groups.isEmpty) + XCTAssertEqual(Set(standardReport.deferredLargeCandidates.map(\.name)), ["large-a.bin", "large-b.bin"]) + + let slowReport = try await DuplicateFinder().scan( + root: root, + options: DuplicateScanOptions( + mode: .includeLargeFiles, + partialHashByteCount: 16, + largeFileThresholdBytes: 128, + maxConcurrentHashes: 2 + ) + ) + XCTAssertEqual(slowReport.groups.count, 1) + XCTAssertEqual(slowReport.groups[0].copies.count, 1) + XCTAssertTrue(slowReport.deferredLargeCandidates.isEmpty) + } + + func testOriginalChoicePrefersPlainShallowOlderPath() { + let older = Date(timeIntervalSince1970: 1_000) + let newer = Date(timeIntervalSince1970: 2_000) + let plain = duplicateFile(path: "/Users/test/report.pdf", createdAt: older) + let backup = duplicateFile(path: "/Users/test/Backups/report copy.pdf", createdAt: newer) + let nested = duplicateFile(path: "/Users/test/Documents/Old/report.pdf", createdAt: older) + + XCTAssertEqual(DuplicateFinder.chooseOriginal(from: [backup, nested, plain]).id, plain.id) + } + + func testScanCanBeCancelled() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + for index in 0..<200 { + try Data(repeating: UInt8(index % 255), count: 128) + .write(to: root.appending(path: "file-\(index).bin")) + } + + let task = Task { + try await DuplicateFinder().scan(root: root) + } + task.cancel() + + do { + _ = try await task.value + XCTFail("Cancelled duplicate scan unexpectedly completed") + } catch is CancellationError { + // Expected. + } + } + + func testCleanupPlannerProtectsOriginalAndRejectsUnknownChangedAndOutsidePaths() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let outsideRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: outsideRoot) } + + let data = Data("same-content".utf8) + let originalURL = root.appending(path: "a.bin") + let copyURL = root.appending(path: "b.bin") + try data.write(to: originalURL) + try data.write(to: copyURL) + + let report = try await DuplicateFinder().scan(root: root) + let group = try XCTUnwrap(report.groups.first) + let originalID = group.original.id + let copyID = try XCTUnwrap(group.copies.first?.id) + + let protectedPlan = DuplicateCleanupPlanner(root: root).plan( + groups: [group], + selectedCopyIDs: [originalID, copyID, "/unknown/path"] + ) + XCTAssertEqual(protectedPlan.items.map(\.id), [copyID]) + XCTAssertEqual(Set(protectedPlan.rejectedItems.map(\.reason)), [.protectedOriginal, .unknownSelection]) + + try Data("DIFF-content".utf8).write(to: copyURL) + let changedPlan = DuplicateCleanupPlanner(root: root).plan( + groups: [group], + selectedCopyIDs: [copyID] + ) + XCTAssertTrue(changedPlan.items.isEmpty) + XCTAssertEqual(changedPlan.rejectedItems.first?.reason, .changedSinceScan) + + let outsideURL = outsideRoot.appending(path: "outside.bin") + try data.write(to: outsideURL) + let outsideFile = try makeDuplicateFile(at: outsideURL) + let forgedGroup = DuplicateGroup( + fullHash: group.fullHash, + original: group.original, + copies: [outsideFile] + ) + let outsidePlan = DuplicateCleanupPlanner(root: root).plan( + groups: [forgedGroup], + selectedCopyIDs: [outsideFile.id] + ) + XCTAssertTrue(outsidePlan.items.isEmpty) + XCTAssertEqual(outsidePlan.rejectedItems.first?.reason, .outsideSelectedRoot) + } + + func testCleanupExecutorMovesOnlyCopyAndLeavesOriginal() async throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let trash = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: trash) } + + let data = Data("keep-one-copy".utf8) + let originalURL = root.appending(path: "a.bin") + let copyURL = root.appending(path: "b.bin") + try data.write(to: originalURL) + try data.write(to: copyURL) + + let scanReport = try await DuplicateFinder().scan(root: root) + let group = try XCTUnwrap(scanReport.groups.first) + let copy = try XCTUnwrap(group.copies.first) + let plan = DuplicateCleanupPlanner(root: root).plan( + groups: [group], + selectedCopyIDs: [copy.id] + ) + let cleanupReport = DuplicateCleanupExecutor { url in + let destination = trash.appending(path: url.lastPathComponent) + try FileManager.default.moveItem(at: url, to: destination) + return destination + }.execute(plan: plan) + + XCTAssertEqual(cleanupReport.movedItems.map(\.id), [copy.id]) + XCTAssertTrue(FileManager.default.fileExists(atPath: group.original.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: copy.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: trash.appending(path: copy.name).path)) + } + + private func makeTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appending(path: "CleanMacDuplicateTests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func duplicateFile(path: String, createdAt: Date?) -> DuplicateFile { + DuplicateFile( + path: path, + name: URL(fileURLWithPath: path).lastPathComponent, + sizeBytes: 100, + createdAt: createdAt, + modifiedAt: nil, + deviceID: 1, + inode: UInt64(abs(path.hashValue)) + ) + } + + private func makeDuplicateFile(at url: URL) throws -> DuplicateFile { + var fileInfo = stat() + let result = url.path.withCString { lstat($0, &fileInfo) } + XCTAssertEqual(result, 0) + let values = try url.resourceValues(forKeys: [ + .fileSizeKey, + .creationDateKey, + .contentModificationDateKey + ]) + return DuplicateFile( + path: url.path, + name: url.lastPathComponent, + sizeBytes: Int64(try XCTUnwrap(values.fileSize)), + createdAt: values.creationDate, + modifiedAt: values.contentModificationDate, + deviceID: UInt64(bitPattern: Int64(fileInfo.st_dev)), + inode: UInt64(fileInfo.st_ino) + ) + } +} diff --git a/contract.md b/contract.md index 12d78d5..32c3e4d 100644 --- a/contract.md +++ b/contract.md @@ -2,81 +2,59 @@ ## Task -- ID: TASK-034 -- Title: Persistent cleanup history +- ID: TASK-041 +- Title: CleanMac v0.3.0 release - Mode: continue ## Planner Notes -- Why this task now: cleanup history and restore already work, but the history is held only in SwiftUI state and disappears when CleanMac is relaunched. -- Expected value: the user can still see and safely restore a previously trashed cleanup item after restarting the app. -- Main risk: a modified or corrupt history file must never become authority to move an arbitrary file. -- UX constraint: preserve the existing Results history UI and Trash-only cleanup behavior; only its lifetime and safety validation change. +- Why this task now: the feature branch has a green CI run and contains several user-visible additions since v0.2.1. +- Expected value: a reproducible v0.3.0 package and GitHub release that match the current app and make the new functionality available together. +- Main risk: publishing an artifact from an unmerged or differently versioned commit, attaching stale ZIP files, or implying Apple notarization when only ad-hoc signing is available. +- Release assumption: the new feature set warrants minor version 0.3.0 with build number 4; GitHub release copy remains English. ## Builder Scope - Allowed files: - - `CleanMacCore/Sources/CleanMacCore/CleanupModels.swift` - - `CleanMacCore/Sources/CleanMacCore/CleanupPlanModels.swift` - - `CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift` - - `CleanMacCore/Sources/CleanMacCore/CleanupRestorer.swift` - - `CleanMacCore/Sources/CleanMacCore/CleanupHistoryStore.swift` - - `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift` - - `CleanMac/Models/CleanMacModels.swift` - - `CleanMac/Views/MainWindowView.swift` - - `CleanMac/Views/ResultsView.swift` - - `CleanMac/en.lproj/Localizable.strings` - - `CleanMac/ru.lproj/Localizable.strings` - - `project-analysis.md` - - `roadmap.md` - - `contract.md` - - `progress.md` - - `trace.md` - - `verification.md` + - `CleanMac.xcodeproj/project.pbxproj` + - generated `dist/CleanMac.app`, ZIP, and SHA-256 assets; + - GitHub PR/release metadata; + - Loop documentation files - Allowed commands: - - localization/plist lint and key-parity checks - - `swift test --package-path CleanMacCore` - - `./script/build_and_run.sh --verify` - - non-destructive UI inspection and screenshots - - focused Git/GitHub commands after verification + - read-only source, Git, bundle, signing, and release inspection; + - `swift test --package-path CleanMacCore`; + - Debug `xcodebuild`; + - `./script/build_and_run.sh --verify`; + - `./script/package_release.sh`; + - PR update/merge and GitHub Release publishing explicitly approved by the user; + - `git diff --check`. - Out of scope: - - new cleanup categories, permanent deletion, application-removal history, launch at login, release/version changes, signing, or notarization; - - adding dependencies or changing the macOS deployment target; - - triggering cleanup or restore against real user files during verification. -- Dependencies allowed: no -- Destructive actions allowed: no + - new product behavior, cleanup execution, permission changes, Developer ID signing, notarization, dependencies, or architecture changes. +- Dependencies allowed: no external dependencies; system CryptoKit only +- Destructive actions allowed: replace generated ignored `dist/` artifacts only; no user-data cleanup ## Evaluator Checklist - Done criteria: - - The history is stored as a versioned JSON file under `Application Support/CleanMac` using atomic replacement. - - Only successful cleanup moves create records, and each operation gets a unique history ID even when the same original path is reused. - - The newest 100 unique records are retained. - - Restored and failed statuses are written back to the store. - - Updates from multiple app windows use read-merge-write semantics and cannot downgrade a stored restored record. - - Persistence failures remain visible in the current window and produce a localized warning instead of a false durability claim. - - Missing or corrupt JSON loads as empty history without a crash or restore attempt. - - A persisted record can restore only from a direct child of the configured Trash root to a strict descendant of its category allowlist; forged paths and symbolic links are rejected before the move handler runs. - - The default move opens every parent path component with `openat(..., O_NOFOLLOW)`, pins both directory descriptors, and uses exclusive `renameatx_np`, so a raced destination cannot follow a substituted symlink or overwrite an existing item. - - The existing history panel explains that records persist across launches. + - Debug and Release bundles both report version 0.3.0 and build 4. + - The feature PR is green and merged into `main` before tagging. + - The final arm64 ZIP is built from the tagged main commit, passes strict signature verification after fresh extraction, and has a matching SHA-256 file. + - GitHub Release v0.3.0 is public, latest, not prerelease, uses English notes, and contains exactly the verified ZIP and SHA-256 assets. + - The distribution note clearly says the build is ad-hoc signed and not Apple-notarized. - Required verification: - - `swift test --package-path CleanMacCore` - - localization lint and RU/EN key parity - - `./script/build_and_run.sh --verify` - - `git diff --check` + - `swift test --package-path CleanMacCore`; + - `./script/build_and_run.sh --verify`; + - `./script/package_release.sh`; + - extracted bundle version/build/architecture/signature inspection; + - local and downloaded SHA-256 verification; + - GitHub CI and release metadata inspection; + - `git diff --check`. - Manual checks: - - Open Results and confirm the persistent-history wording fits in Russian. - - Do not trigger real cleanup or restore. - -## Restart Signals - -Restart or shrink the task if: -- persisted restoration cannot be revalidated without weakening the existing allowlist; -- Swift concurrency makes file persistence block or race with cleanup state; -- a history migration would require trusting an unversioned legacy format. + - Confirm the About metadata and packaged bundle both show 0.3.0 (4). + - Confirm Gatekeeper limitations are documented without recommending security bypasses. ## Result -- Status: complete -- Verification result: passed. All 23 core tests, localization lint/key parity, `git diff --check`, Debug build/launch, and read-only Russian Results inspection passed. -- Notes: No real cleanup or restore was triggered. The focused forged-destination test initially exposed a symlink-parent gap; canonical-parent rebuilding plus descriptor-relative exclusive rename resolved it. The known stale CoreSimulator warning remains non-blocking for macOS builds. +- Status: in progress +- Verification result: pending final main-branch package and GitHub release validation. +- Notes: no Developer ID identity is installed, so the release will remain transparently labeled ad-hoc signed and not notarized. diff --git a/progress.md b/progress.md index f3d4b2a..3204ce0 100644 --- a/progress.md +++ b/progress.md @@ -290,3 +290,83 @@ Append-only history. Do not erase previous entries. - Next step: Add read-only large-file review or deeper developer-storage previews. - Bottleneck: none. Developer ID signing/notarization still separately requires Apple credentials. - Handoff: The app remains on the Russian/light Results screen. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-12 - TASK-035 - Read-only disk analysis + +- What changed: Added a separate Disk Analysis sidebar section backed by a cancellable read-only `CleanMacCore` scanner. It offers no-exclusion whole-disk scanning from `/`, Home, Downloads, and a native custom-folder picker; one scan powers a bounded radial multi-ring map with folder drill-down/breadcrumbs and a large-file list with 50/100/500 MB/1 GB filters, size/date/type sorting, nil initial selection, and Finder/Open actions. Analyzer results never enter cleanup reports, junk totals, cleanup selection, history, or scheduled scans. +- Files touched: `CleanMacCore/Sources/CleanMacCore/DiskAnalyzer.swift`, `CleanMacCore/Tests/CleanMacCoreTests/DiskAnalyzerTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Support/DiskAnalysisWorkspaceService.swift`, `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DiskSunburstView.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (28/28); localization plist lint and RU/EN key parity; `git diff --check`; repeated Debug `xcodebuild`; repeated `./script/build_and_run.sh --verify`; live Russian/light Home and whole-disk scans; visual radial-map and large-file review; nil initial row selection/action-state check; no-exclusion `/` scan through system/application/user/developer paths. +- Result: Passed. The live whole-disk scan visited 1,316,113 accessible objects, measured 68.73 GB, found 124 files over 50 MB, and showed Applications, System, Users, Library, var, opt, usr, tmp, and bin branches. It reported 457 macOS-protected locations instead of escalating privileges. No file was selected automatically and no cleanup or mutation occurred. +- Next step: Add launch-at-login support or deeper developer-storage previews. +- Bottleneck: a truly complete physical-volume total is limited by macOS access controls and APFS/mounted representations; the analyzer reports what the current process can read and explains why Finder may differ. +- Handoff: Whole-disk mode deliberately has no path exclusion list per user direction. It can be slower and can include mounted paths. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-12 - TASK-035 - Disk analysis interaction polish + +- What changed: Replaced the standard scan spinner with a custom gradient ring/pulse indicator and added efficient radial-map hover hit-testing. The hovered sector now grows with a short animation, glow, and localized floating tooltip containing its folder name and binary-gigabyte size; Reduce Motion disables continuous movement. +- Files touched: `CleanMac/Views/DiskAnalysisProgressIndicator.swift`, `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DiskSunburstView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (28/28); localization plist lint and RU/EN key parity; `git diff --check`; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live Russian/light whole-disk progress review; live map hover review showing `.cache` at `1.510 ГБ`; cancellation smoke check. +- Result: Passed. The modern indicator renders during a live `/` scan, the hovered sector enlarges and displays its GB tooltip, and cancellation returns without modifying files. +- Next step: Decide whether to add launch-at-login support or a rectangular treemap alternative. +- Bottleneck: none. macOS access controls still limit unreadable protected paths, and the stale CoreSimulator warning remains non-blocking for macOS builds. +- Handoff: The verification scan was cancelled through the UI after the indicator check. No cleanup, application removal, restore, or file mutation was triggered. + +## 2026-07-12 - TASK-036 - First-launch system onboarding + +- What changed: Added a four-step first-launch flow inside the primary CleanMac window: Welcome, real product capabilities, optional Full Disk Access guidance with live read-only status, and completion. The screen uses semantic macOS colors/materials and the system appearance rather than the saved in-app theme, supports Back/Next/Skip, Return as the default action, Reduce Motion, RU/EN localization, and persists completion through `CleanMac.onboardingCompleted`. +- Files touched: `CleanMac/CleanMacApp.swift`, `CleanMac/Support/CleanMacPreferences.swift`, `CleanMac/Views/OnboardingView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (28/28); localization plist lint and RU/EN key parity; `git diff --check`; Debug `xcodebuild`; repeated `./script/build_and_run.sh --verify`; live Russian/light review of all four pages; Back and top-right Skip checks; completion-to-Dashboard check; completed relaunch check. +- Result: Passed. Onboarding is the only primary-window content before completion, all four pages fit the standard window, permission settings are explicit-only, both finishing and skipping store completion, and the next launch opens Dashboard directly. +- Next step: Decide whether Settings should expose a "Show onboarding again" action. +- Bottleneck: none. The known generated-app FinderInfo attribute required the already documented build-product cleanup before the successful verification rerun; the stale CoreSimulator warning remains non-blocking. +- Handoff: The completion flag was deleted after verification so the new onboarding is visible on the user's next launch. No System Settings button, scan, cleanup, app removal, or restore action was triggered. + +## 2026-07-12 - TASK-036 - Release artifact refresh + +- What changed: Rebuilt `Documents/CleanMac/dist/CleanMac.app` from commit `53e68ff`, created the matching unsigned ZIP and SHA-256 file, and hardened the packaging script by sanitizing File Provider metadata between its nested and outer signing passes. +- Files touched: `script/package_release.sh`, `progress.md`, `trace.md`. +- Checks run: `./script/package_release.sh`; fresh-ZIP strict codesign verification; local app strict codesign verification after removing FinderInfo; `shasum -a 256 -c dist/CleanMac-53e68ff-unsigned.zip.sha256`. +- Result: Passed. The current Release executable was produced on 2026-07-12 12:27:17, the ZIP checksum is `5062e433beb9eca85aa73a85d15fd7b08008e9981ed594da50f6a14d50b71ac3`, and both the local app and fresh ZIP extraction satisfy their designated requirements. +- Next step: Push the feature branch and open the protected-main pull request. +- Bottleneck: no Developer ID identity is installed, so the artifact remains ad-hoc signed and not notarized. +- Handoff: The separate `/Users/admin/Desktop/CleanMac` project copy remains untouched and still contains version 1.0 (1) artifacts. + +## 2026-07-12 - TASK-037 - Live system dashboard menu bar + +- What changed: Rebuilt the menu-bar popover as a compact 2x2 live dashboard based on the supplied layout. CPU, memory, disk, battery, network rates, and uptime update locally only while the popover is visible; the existing scan state and last-scan summary remain available. The final styling uses adaptive macOS materials, semantic text colors, and the CleanMac accent, and explicitly injects the selected CleanMac light/dark scheme because `MenuBarExtra` ignored `preferredColorScheme` in the live app. +- Files touched: `CleanMac/CleanMacApp.swift`, `CleanMac/Support/StatusSystemMetrics.swift`, `CleanMac/Views/StatusMenuView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (28/28); localization plist lint and RU/EN key parity; `git diff --check`; Debug `xcodebuild`; repeated `./script/build_and_run.sh --verify`; live Russian light/dark popover review with screenshots `/tmp/cleanmac-task37-menu-light-final.png` and `/tmp/cleanmac-task37-menu-dark-fixed.png`. +- Result: Passed. The live light setting renders a light system popover, the dark setting renders a dark popover matching the main window, gauges and values update without background persistence, battery degrades safely when unavailable, and no cleanup or scan was triggered. +- Next step: Decide whether to expose a compact menu-bar refresh interval setting; the current one-second visible-only interval is intentionally fixed and lightweight. +- Bottleneck: none. The known stale CoreSimulator warning remains non-blocking, and the generated app FinderInfo attribute required the documented build-product cleanup before the successful launch verification rerun. +- Handoff: The user's original `light` appearance preference was restored after the dark-theme review. CleanMac remains running from the current Debug product. + +## 2026-07-12 - TASK-038 - Settings permissions and launch at login + +- What changed: Removed Permissions from the sidebar and embedded its complete Full Disk Access, Files/Folders, and Finder Automation status/actions in Settings. Added a `LaunchAtLoginManager` backed by `SMAppService.mainApp`, with off-main register/unregister calls, live enabled/not-registered/approval-required/not-found status, progress feedback, localized failure details, and a Login Items System Settings action. Reworked initial window presentation so login/background launch keeps the window hidden without a flash, while app activation, Dock/Finder reopen, and menu-bar Open reveal the same existing window. +- Files touched: `CleanMac/CleanMacApp.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Support/LaunchAtLoginManager.swift`, `CleanMac/Support/MainWindowController.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/PermissionsView.swift`, `CleanMac/Views/SettingsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `script/package_release.sh`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (28/28); localization plist lint and RU/EN key parity; `git diff --check`; repeated Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live Russian Settings accessibility and screenshot review; read-only `.notFound` Login Item status; normal/background/activation/menu-bar Open lifecycle checks; `bash -n script/package_release.sh`; `./script/package_release.sh`; fresh-ZIP strict codesign verification and SHA-256 check. +- Result: Passed. Sidebar contains five product sections plus Settings and no Permissions row; all permission controls render in Settings. A background `open -g` launch produced zero main windows, activation produced one main window, and menu-bar Open restored the window from the background process. The Login Item itself was not enabled or disabled during verification. `dist/CleanMac.app` and `CleanMac-13fc508-unsigned.zip` now contain this task's build. +- Next step: Install the packaged app in `/Applications` and let the user explicitly enable the toggle to confirm the real `.enabled` or `.requiresApproval` path. +- Bottleneck: the Debug copy under `Documents` correctly reports `.notFound`; macOS Login Item registration should be exercised from an installed app bundle, and public distribution remains ad-hoc signed until Developer ID credentials exist. +- Handoff: No Login Item, privacy permission, scan, cleanup, application removal, or notification setting was changed. CleanMac is running from the current Debug product. + +## 2026-07-12 - TASK-039 - Advanced developer cleanup + +- What changed: Added precise cleanup categories for Homebrew, pip, Cargo registry cache/source, Gradle, Cursor and VS Code cache folders, Codex and Claude cache/temp folders, Xcode DeviceSupport, Xcode Previews, old user Simulator devices/runtimes, and individual `.xcarchive` bundles. Simulator candidates must be unchanged for 180 days. DeviceSupport, Simulator, and Archives require review; Archives are never selected by default. The planner accepts only individual archives and direct Xcode/Simulator children, while exact forbidden-path tests protect IDE settings/extensions and AI projects/sessions/history/shell snapshots/memory. +- Files touched: `CleanMacCore/Sources/CleanMacCore/CleanupModels.swift`, `CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift`, `CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift`, `CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift`, `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (32/32); localization plist lint and RU/EN key parity; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; `pgrep -x CleanMac`; live Russian Scan accessibility review; `git diff --check`. +- Result: Passed. All new rows fit the live Scan screen. Existing saved area choices stayed unchanged; every new review category was off, and Xcode Archives visibly states “Требует проверки”. No forbidden user-data path appeared in scanner roots or plans. +- Next step: If system-managed Simulator runtimes must be handled later, add read-only Xcode runtime discovery and an explicit Xcode-supported removal workflow instead of Trash-based `/Library` deletion. +- Bottleneck: modern system-managed Simulator runtimes live outside the user's home and cannot be safely handled by the current unprivileged Trash executor. The known stale CoreSimulator framework warning remains non-blocking for macOS builds. +- Handoff: No real scan, cleanup, archive move, Simulator change, or preference reset occurred. CleanMac remains running from the current Debug product on the Scan screen. + +## 2026-07-12 - TASK-040 - Safe duplicate finder + +- What changed: Added a separate Duplicate Finder for Home, Downloads, and a user-selected folder. The core pipeline groups by logical size, hashes only the first 128 KiB, streams full SHA-256 only for surviving candidates, removes hard links by device/inode, limits hashes to two concurrent tasks by default, and supports an explicit slow mode for matching-size files over 500 MiB instead of hiding them. Every result group has one locked deterministic original and unselected copies. Cleanup requires a dedicated confirmation, revalidates scanned metadata and root containment, and moves only selected copies to macOS Trash. +- Files touched: `CleanMacCore/Sources/CleanMacCore/DuplicateFinder.swift`, `CleanMacCore/Sources/CleanMacCore/DuplicateCleanup.swift`, `CleanMacCore/Tests/CleanMacCoreTests/DuplicateFinderTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Support/DuplicateWorkspaceService.swift`, `CleanMac/Views/DuplicateFinderView.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: focused duplicate tests and full `swift test --package-path CleanMacCore` (40/40); localization plist lint and RU/EN key parity; `git diff --check`; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; `pgrep -x CleanMac`; live Russian initial-screen accessibility and visual review. +- Result: Passed. No file is automatically selected; originals have no checkbox; large matching-size candidates are shown for opt-in slow hashing; duplicate data stays out of junk totals, normal Results, cleanup history, and scheduled scans. No real folder scan or cleanup was performed during UI verification. +- Next step: Exercise the result and confirmation layout on a user-approved disposable folder, then cut a release only when requested. +- Bottleneck: Full hashing of very large files is intentionally slow and remains an explicit opt-in; hidden files and package contents stay outside this stage. +- Handoff: The current Debug app is running on the Russian Duplicate Finder initial screen. No user file was scanned or moved, and no destructive confirmation was accepted. diff --git a/project-analysis.md b/project-analysis.md index 537e11f..92e1b48 100644 --- a/project-analysis.md +++ b/project-analysis.md @@ -33,15 +33,19 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa ## Completed Parts +- First launch now opens a localized four-step system-adaptive onboarding inside the primary window. It introduces only shipped CleanMac capabilities, checks Full Disk Access without prompting, opens privacy settings only from an explicit button, and persists completion or skip so later launches open the main UI directly. - Repository was cleaned and renamed to CleanMac. - App icon, menu bar icon, Dashboard brand icon, status menu brand icon, design assets, and docs icon use the supplied detailed broom artwork from `Design/source-icon.png`. - Public GitHub repository and CI were configured. - Local `dist/` packaging exists and is ignored by git. -- Main Dashboard/Scan/Results/Applications/Permissions/Settings window builds and launches. +- Main Dashboard/Scan/Results/Applications/Settings window builds and launches; live permission controls are consolidated inside Settings instead of occupying a separate sidebar destination. - Main content pages use the supplied light technology background while preserving the native macOS sidebar. - Sidebar navigation uses subtle modern hover, click, and keyboard focus feedback while preserving the selected accent row, and the footer has persistent RU/EN language and light/dark appearance controls; language still defaults from system preferences when no override exists. - Menu bar Open focuses the existing main window before creating a new one. - `CleanMacCore` has a read-only scanner for user caches, logs, temporary files, Trash, Downloads review, Xcode Derived Data, browser caches, Node/npm/Yarn/pnpm caches, SwiftPM cache, and downloaded installers. +- Advanced developer cleanup uses exact allowlists for Homebrew, pip, Cargo registry cache/source, Gradle, Cursor/VS Code caches, Codex/Claude cache/temp folders, Xcode DeviceSupport, Previews, old user Simulator data, and individual Xcode Archives. IDE settings/extensions and AI projects/sessions/history/memory are excluded; Simulator and Archives require review, and Archives are never selected by default. +- Disk Analysis is a separate read-only workspace with whole-disk (`/` with no path exclusions), Home, Downloads, and custom-folder sources. One cancellable scan powers a bounded multi-ring folder map plus a non-selected large-file list with 50 MB, 100 MB, 500 MB, and 1 GB filters, size/date/type sorting, and Finder/Open actions. Its data never enters cleanup reports, junk totals, history, or scheduled scans. +- Duplicate Finder is a separate Home, Downloads, or custom-folder workspace. It narrows candidates by logical size and a first-block SHA-256 before streaming the full SHA-256 only for survivors, excludes hard links, limits hashing concurrency, protects one deterministic original in every group, starts with no selected copies, and moves only explicitly confirmed unchanged copies to Trash. Standard mode reports matching-size files over 500 MiB without hiding them; an optional slow mode hashes them. - Results UI is backed by real scanner output, safe results are selected by default, and cleanup requires explicit confirmation. - Safe Mode now keeps review-risk results visible but unselectable, clears stale review selections when enabled, and rechecks risk immediately before cleanup execution. - Results now explain why each item was suggested, using structured reasons produced by the scanner rules. @@ -55,16 +59,17 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - Scan UI shows a modern animated activity surface during active scans, with Reduce Motion support and selected-area chips. - Scan UI now binds to real scanner progress, including current area, percentage, found count, and measured size while scanning. - Downloads, logs, and temporary files use conservative smart rules to reduce noisy candidates from recent small downloads and likely active files. -- Permissions UI checks live Full Disk Access status by probing protected metadata/readability and can refresh the result. +- Settings includes the complete live Permissions UI, checks Full Disk Access by probing protected metadata/readability, and can refresh or open the relevant system panes. - Finder Automation now shows the live Apple Events consent state, requests access only from an explicit button, and uses the permission to reveal selected items while preserving the NSWorkspace fallback. - English and Russian app localizations are included; macOS selects the language from system preferences. - The standard About panel is replaced by a centered singleton CleanMac window with live version/build metadata, local-first safety details, project links, and immediate RU/EN plus light/dark updates. - The selected language override applies through the app's localizer, and the selected appearance applies to both the main window and menu bar popover. -- The menu bar popover shows current disk usage, scan-in-progress state, last scan source/time, and last scan result summary with larger readable typography and rounded compact panels. +- The menu bar popover is a compact live dashboard with CPU, memory, disk, battery, network, uptime, scan state, and last-scan summary. Its 2x2 gauge layout uses system materials and follows the light/dark appearance selected in CleanMac rather than a fixed reference color scheme. - Settings can enable read-only auto scan while the app is running; it supports daily, hourly, and every-two-hours frequencies, uses the currently selected scan areas, and updates menu bar status. +- Settings can register the main app through `SMAppService.mainApp` to launch at login, displays the authoritative macOS enabled/disabled/approval/unavailable status, reports registration failures, and links to Login Items when user action is required. Login-style background launch keeps the main window hidden until the user activates CleanMac or chooses Open from the menu bar. - Scheduled auto scan can show localized macOS completion notifications when the notification toggle is enabled and system permission allows it; Settings includes a test notification button to diagnose macOS permission/delivery state. Manual scans remain silent. -- Public GitHub Release `v0.2.1` is the latest release and includes the verified arm64 ad-hoc zip and sha256 assets. -- Release packaging creates a clean unsigned/ad-hoc local zip plus sha256, strips Finder/resource-fork metadata before archiving, strictly verifies a fresh ZIP extraction, and can optionally sign with Developer ID, enable hardened runtime, submit to Apple notary service, staple, and re-zip when credentials are configured. +- Public GitHub Release `v0.3.0` is the latest release and includes the verified arm64 ad-hoc zip and sha256 assets. +- Release packaging creates a clean unsigned/ad-hoc local zip plus sha256, strips Finder/resource-fork metadata before archiving, retries codesign when File Provider reattaches metadata between passes, strictly verifies a fresh ZIP extraction, and can optionally sign with Developer ID, enable hardened runtime, submit to Apple notary service, staple, and re-zip when credentials are configured. ## Unfinished Or Risky Parts @@ -72,9 +77,11 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - Permissions are live for Full Disk Access status, but the app still relies on System Settings for granting access. - The scanner is intentionally conservative and capped; deeper stale-file heuristics and persistent cleanup previews are future work. - Cleanup is intentionally Trash-based; permanent deletion is still out of scope. -- Scheduled auto scan currently runs only while the CleanMac app process is running; launch-at-login or a privileged background agent is still future work. +- Duplicate scanning intentionally skips hidden files and package contents, stops at a documented file-count safety limit, and can take substantial time in the explicit large-file mode. Its results remain isolated from junk totals, normal cleanup history, and scheduled scans. +- Scheduled auto scan still runs inside the CleanMac process rather than a privileged agent, but the optional Login Item can now start that process automatically after macOS login. - Notification delivery depends on the macOS notification permission for CleanMac; if permission is denied, scheduled scans still complete silently. - Root-owned or otherwise protected third-party apps may fail to move without administrator privileges; CleanMac reports the failure and does not escalate privileges or remove leftovers. +- Whole-disk analysis intentionally attempts every path beneath `/`, including system and mounted-volume paths. macOS-protected locations remain unreadable without the required system access, while APFS firmlinks/mounted representations can make the measured total differ from Finder; the UI reports these limitations and never treats the result as junk. The analysis UI uses a custom Reduce Motion-aware progress indicator and an interactive radial map whose hovered sector enlarges and reports its exact size in GB. - Local Xcode emits a CoreSimulator warning; it does not currently block macOS builds. ## Strengths @@ -93,6 +100,5 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa ## Recommended Next Work 1. Configure Apple Developer signing secrets and cut a signed/notarized release. -2. Add launch-at-login support so scheduled scans can happen after reboot/login without manually opening CleanMac. -3. Add read-only large-file review and deeper developer storage previews. -4. Decide whether application removals should have a separate, equally constrained history model. +2. Decide whether to add read-only system-managed Simulator runtime guidance through Xcode tooling rather than moving `/Library` bundles directly. +3. Decide whether application removals should have a separate, equally constrained history model. diff --git a/roadmap.md b/roadmap.md index ea6bb33..a11b44c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,5 +1,103 @@ # Roadmap +- [x] ID: TASK-041 + Title: CleanMac v0.3.0 release + Goal: Ship the verified disk analysis, onboarding, system dashboard, developer cleanup, and duplicate finder as the next feature release. + What to do: Bump version/build, merge the green feature PR, rebuild the ad-hoc arm64 distribution, verify its signature/version/checksum, and publish English GitHub release notes with ZIP and SHA-256 assets. + Files: Xcode version settings, release artifacts, Loop docs, GitHub PR/release metadata + Definition of done: main contains the feature work; `dist/CleanMac.app` reports 0.3.0 (4); the extracted ZIP passes strict ad-hoc signature verification; checksum matches; v0.3.0 is the latest GitHub release. + Verification: `swift test --package-path CleanMacCore`; `./script/build_and_run.sh --verify`; `./script/package_release.sh`; bundle metadata/architecture/signature/checksum inspection; green GitHub CI; downloaded release-asset verification + Priority: high + Impact: high + Risk: medium + Effort: small + Confidence: high + Score: high impact / medium risk / small + +- [x] ID: TASK-040 + Title: Safe duplicate finder + Goal: Find exact duplicate files through staged SHA-256 hashing while always preserving one original and requiring explicit Trash review. + What to do: Add size/partial/full-hash stages, hard-link exclusion, a bounded slow mode for files over 500 MiB, a separate grouped UI, and a copy-only Trash planner/executor. + Files: duplicate core models/scanner/planner/tests, duplicate SwiftUI screen/workspace service, navigation/localization, Loop docs + Definition of done: no automatic selection; large candidates are reported or hashed in slow mode; originals cannot be selected; only unchanged validated copies can move to Trash after confirmation. + Verification: focused safety/performance tests; localization lint/key parity; `swift test --package-path CleanMacCore`; Debug build; `./script/build_and_run.sh --verify`; temporary-fixture UI review; `git diff --check` + Priority: high + Impact: high + Risk: high + Effort: large + Confidence: high + Score: high impact / high risk / large + +- [x] ID: TASK-039 + Title: Advanced developer cleanup + Goal: Add precise cleanup categories for package managers, IDEs, AI tools, and Xcode storage without touching developer settings, extensions, projects, history, or memory. + What to do: Add exact allowlist roots, localized category/reason copy, 180-day Simulator review filtering, and review-only non-default Xcode Archives. + Files: cleanup core models/scanner/policy/tests, app catalog/localization, Loop docs + Definition of done: reproducible caches are discoverable; sensitive neighboring data is excluded by tests; Simulator and Archives stay manual review; Archives is never selected by default. + Verification: focused safety tests; `swift test --package-path CleanMacCore`; localization lint/key parity; Debug build; `./script/build_and_run.sh --verify`; read-only Scan review; `git diff --check` + Priority: high + Impact: high + Risk: high + Effort: medium + Confidence: high + Score: high impact / high risk / medium + +- [x] ID: TASK-038 + Title: Settings permissions and launch at login + Goal: Consolidate access controls in Settings and keep scheduled CleanMac work available after macOS login without opening the main window. + What to do: Remove Permissions from the sidebar, embed its live controls in Settings, add `SMAppService.mainApp` registration/status/error handling, and stop forcing app activation during background launch. + Files: app lifecycle, section model/navigation, Settings/Permissions views, launch-at-login service, localization, Loop docs + Definition of done: Settings is the single configuration destination; Login Item reflects macOS truth, handles denial/approval clearly, and background launch leaves the main window closed while normal launch and menu-bar Open still work. + Verification: localization lint/key parity; `swift test --package-path CleanMacCore`; Debug build; `./script/build_and_run.sh --verify`; read-only Login Item status; foreground/background launch review; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: medium + Confidence: high + Score: high impact / medium risk / medium + +- [x] ID: TASK-037 + Title: Live system dashboard menu bar + Goal: Rebuild the menu-bar popover as a compact live system dashboard based on the supplied Mac Sai layout. + What to do: Add lightweight local CPU/memory/disk/battery/network/uptime sampling, an adaptive light/dark 2x2 gauge layout, compact scan state, and reference-style bottom actions. + Files: `CleanMac/Support/StatusSystemMetrics.swift`, `CleanMac/Views/StatusMenuView.swift`, app scene styling, localization, Loop docs + Definition of done: Live metrics refresh only while open, unavailable battery degrades safely, existing scan state remains visible, actions work, and RU/EN content fits the popover. + Verification: localization lint/key parity; `swift test --package-path CleanMacCore`; Debug build; `./script/build_and_run.sh --verify`; live menu screenshot/accessibility review; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: medium + Confidence: high + Score: high impact / medium risk / medium + +- [x] ID: TASK-036 + Title: First-launch system onboarding + Goal: Introduce CleanMac on first launch with a native four-step flow matching the supplied reference structure. + What to do: Add system-adaptive Welcome, capabilities, Full Disk Access, and completion pages inside the primary launch window, with persistent completion and explicit-only System Settings access. + Files: `CleanMac/CleanMacApp.swift`, `CleanMac/Support/CleanMacPreferences.swift`, `CleanMac/Views/OnboardingView.swift`, localization, Loop docs + Definition of done: Onboarding appears only before completion, follows system appearance, supports Back/Next/Skip, describes only shipped features, never requests access automatically, and a completed relaunch opens the main UI directly. + Verification: localization lint/key parity; `swift test --package-path CleanMacCore`; Debug build; `./script/build_and_run.sh --verify`; first-launch/relaunch visual review; `git diff --check` + Priority: high + Impact: high + Risk: low + Effort: medium + Confidence: high + Score: high impact / low risk / medium + +- [x] ID: TASK-035 + Title: Read-only disk analysis + Goal: Explain disk usage with a large-file review and an interactive radial folder map without classifying personal files as junk. + What to do: Add a cancellable bounded analyzer in `CleanMacCore`, whole-disk/Home/Downloads/custom-folder sources, 50 MB–1 GB filters, size/date/type sorting, Finder/Open actions, a modern animated scan indicator, and a multi-ring drill-down map inspired by the supplied reference. + Files: `CleanMacCore/Sources/CleanMacCore/DiskAnalyzer.swift`, focused core tests, `CleanMac/Views/DiskAnalysisView.swift`, narrow AppKit folder/workspace service, navigation/localization, Loop docs + Definition of done: One read-only scan powers both modes; no file is selected automatically; map sectors drill into folders and animate a localized GB tooltip on hover; analyzer results stay out of all cleanup totals, plans, history, and scheduled scans. + Verification: `swift test --package-path CleanMacCore`; localization lint/key parity; Debug build; `./script/build_and_run.sh --verify`; read-only visual review; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: large + Confidence: high + Score: high impact / medium risk / large + - [x] ID: TASK-034 Title: Persistent cleanup history Goal: Preserve Trash-based cleanup history across app launches without trusting stored paths blindly. diff --git a/script/package_release.sh b/script/package_release.sh index 094711a..5e57437 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -46,6 +46,26 @@ sanitize_app_bundle() { xattr -dr com.apple.ResourceFork "$app_path" 2>/dev/null || true } +codesign_clean_bundle() { + local app_path="$1" + shift + local attempt + + for attempt in 1 2 3; do + sanitize_app_bundle "$app_path" + if codesign "$@" "$app_path"; then + return 0 + fi + + if [[ "$attempt" -lt 3 ]]; then + echo "codesign metadata race; retrying ($attempt/3)..." >&2 + sleep 0.2 + fi + done + + return 1 +} + create_zip() { local zip_path="$1" local zip_root="$DIST_DIR/.ziproot" @@ -112,16 +132,14 @@ sanitize_app_bundle "$DIST_APP" if [[ -n "$SIGN_IDENTITY" ]]; then echo "Signing $DIST_APP with: $SIGN_IDENTITY" - codesign --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" "$DIST_APP" - codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGN_IDENTITY" "$DIST_APP" - sanitize_app_bundle "$DIST_APP" - codesign --verify --deep --strict --verbose=2 "$DIST_APP" + codesign_clean_bundle "$DIST_APP" --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" + codesign_clean_bundle "$DIST_APP" --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGN_IDENTITY" + codesign_clean_bundle "$DIST_APP" --verify --deep --strict --verbose=2 else echo "No CLEANMAC_SIGN_IDENTITY configured; applying ad-hoc signature for local validation." - codesign --force --deep --options runtime --sign - "$DIST_APP" - codesign --force --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign - "$DIST_APP" - sanitize_app_bundle "$DIST_APP" - codesign --verify --deep --strict --verbose=2 "$DIST_APP" + codesign_clean_bundle "$DIST_APP" --force --deep --options runtime --sign - + codesign_clean_bundle "$DIST_APP" --force --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign - + codesign_clean_bundle "$DIST_APP" --verify --deep --strict --verbose=2 fi create_zip "$ZIP_PATH" diff --git a/trace.md b/trace.md index 2aaadc5..076e7e7 100644 --- a/trace.md +++ b/trace.md @@ -50,3 +50,59 @@ Append-only trace of failures, restarts, and judgment divergences. - Cause: each window saved its complete local array with `try?`, while the default restore ended in path-based `FileManager.moveItem` after canonical checks. - Fix: history writes now read, merge, and atomically replace records while preserving terminal restored state; the UI reports write failures; the production move walks every source/destination directory component through `openat(..., O_NOFOLLOW)`, pins the resulting descriptors, and uses `renameatx_np(RENAME_EXCL)`. - Status: resolved; the multi-window merge regression, direct/nested/symlink Trash fixtures, intermediate-component regression, exclusive no-overwrite restore tests, all 23 core tests, and the app build/launch pass. + +## 2026-07-12 - TASK-035 - Bounded map root starvation + +- Symptom: the first live Home scan collapsed 7.44 GB into the root `Other` segment even though later top-level folders should have been visible. +- Cause: depth-first enumeration let one early deep subtree consume the global 5,000-node map budget before later root folders were registered. +- Fix: kept the bounded deep-tree budget but reserved a capped set of direct root nodes, so later top-level folders retain truthful totals and only unavailable deeper detail collapses into `Other`; added a regression with a deep early folder and a large late root folder. +- Status: resolved; 28 core tests pass and the live whole-disk map shows distinct Applications, System, Users, Library, var, opt, usr, tmp, and bin branches. + +## 2026-07-12 - TASK-035 - Generated app Finder metadata + +- Symptom: the first final `./script/build_and_run.sh --verify` pass built successfully but ad-hoc signing rejected `com.apple.FinderInfo` on the generated Debug app. +- Cause: Finder/File Provider metadata was attached to the build product, matching the previously documented local signing behavior. +- Fix: cleared extended attributes only from `build/XcodeData/Build/Products/Debug/CleanMac.app` and repeated the standard verification command. +- Status: resolved; the repeated check reports a valid on-disk signature and satisfied designated requirement. + +## 2026-07-12 - TASK-036 - Repeated generated app Finder metadata + +- Symptom: the first onboarding launch verification built successfully but signing again rejected `com.apple.FinderInfo` on the generated Debug app. +- Cause: the same local Finder/File Provider metadata behavior already documented for TASK-035 recurred after rebuilding the app. +- Fix: cleared extended attributes only from the generated Debug `.app` and repeated the standard verification command without touching source or user files. +- Status: resolved; the final app is valid on disk, satisfies its designated requirement, and launches into the first onboarding page. + +## 2026-07-12 - TASK-036 - Finder metadata between release signing passes + +- Symptom: the first Release packaging run copied the current app into `dist`, but the second codesign pass rejected FinderInfo that File Provider attached after the first pass. +- Cause: the script sanitized before and after the pair of signing commands, leaving a short unsanitized interval between nested-code signing and outer-app signing. +- Fix: sanitize the generated app between both signing passes for Developer ID and ad-hoc paths, then retain the existing fresh-ZIP verification. +- Status: resolved; packaging completes, the ZIP checksum passes, and strict signature verification succeeds for both the fresh extraction and the sanitized local `dist/CleanMac.app`. + +## 2026-07-12 - TASK-037 - Menu popover ignored selected appearance + +- Symptom: the first reference-based menu used a fixed purple palette; after replacing it with semantic colors, live dark-theme review still showed a light popover while the main CleanMac window was dark. +- Cause: the `.window` `MenuBarExtra` presentation did not honor `preferredColorScheme`, so its SwiftUI environment remained light even though the stored CleanMac appearance was dark. +- Fix: removed the fixed reference palette, used adaptive system materials/semantic colors, and injected `CleanMacAppearance.colorScheme` directly into the menu popover environment. +- Status: resolved; live Russian screenshots confirm distinct light and dark popovers, and the original light preference was restored after review. + +## 2026-07-12 - TASK-038 - Background launch window lifecycle + +- Symptom: removing unconditional `NSApp.activate` stopped forced foreground activation, but SwiftUI still created a main window during background launch; the first immediate suppression also hid the initial window during a normal test launch because `NSApp.isActive` was still false in `didFinishLaunching`. +- Cause: initial SwiftUI window creation and macOS activation settle on different lifecycle turns, and `applicationShouldHandleReopen` is not guaranteed for the first process launch. +- Fix: create the initial window transparent for 0.2 seconds, then show it if the app became active or order it out if the launch stayed in the background; activation, reopen, and menu-bar Open clear suppression and reveal the existing window. +- Status: resolved; background launch has zero main windows, explicit activation shows one, and menu-bar Open restores it. The initial build also required a direct `ServiceManagement` import in `SettingsView`, and the known FinderInfo build-product attribute was cleared before the successful signed launch rerun. + +## 2026-07-12 - TASK-038 - File Provider metadata during Release signing + +- Symptom: the first local package refresh built Release successfully, but File Provider attached `com.apple.FinderInfo` after sanitization and before the second outer codesign pass. +- Cause: sanitizing once between signing steps still leaves a narrow external metadata race on the `Documents`-backed `dist` folder. +- Fix: wrapped every app signing and local verification pass in a bounded three-attempt sanitize-and-retry helper, preserving strict fresh-ZIP verification as the final source of truth. +- Status: resolved for the distributable; packaging created `CleanMac-13fc508-unsigned.zip` and its fresh extraction satisfies the designated requirement. File Provider can still reattach FinderInfo later to the convenience `dist/CleanMac.app`, so the verified ZIP remains the release source of truth. + +## 2026-07-12 - TASK-040 - Repeated generated app Finder metadata + +- Symptom: the first duplicate-finder launch verification built successfully but ad-hoc signing rejected `com.apple.FinderInfo` on the generated Debug app. +- Cause: the known local Finder/File Provider metadata behavior recurred on the build product. +- Fix: cleared extended attributes only from `build/XcodeData/Build/Products/Debug/CleanMac.app` and repeated the standard verification command without touching source or user files. +- Status: resolved; the repeated verification reported a valid on-disk signature and launched the current Debug app. diff --git a/verification.md b/verification.md index 52fc56f..0954d3e 100644 --- a/verification.md +++ b/verification.md @@ -46,10 +46,16 @@ | Area | Command or check | When to run | Success signal | Fallback | | --- | --- | --- | --- | --- | | macOS app launch | `./script/build_and_run.sh --verify` | UI, app lifecycle, assets, or project changes | Build succeeds and `pgrep -x CleanMac` finds the app | Run the xcodebuild command from this file and inspect launch logs | +| Menu bar dashboard | Open the status item in both CleanMac appearance modes and observe at least two refreshes | Menu-bar layout, metrics, localization, appearance, or actions change | Light selection renders a light popover, dark selection renders a dark popover, live metric values remain valid, and Open focuses the main window | Inspect the accessibility tree and capture light/dark screenshots; do not trigger scan, cleanup, or removal actions | +| Settings and launch at login | Inspect `SMAppService.mainApp.status`, launch once normally and once with `open -g`, then restore through app activation and menu-bar Open | Settings navigation, permissions placement, Login Item, or app launch lifecycle changes | Permissions exists only inside Settings; system status is localized; background launch has zero main windows; activation/menu-bar Open reveals the existing window | Do not toggle Login Item during automated verification; use the current read-only status and inspect registration/error paths in code | +| First-launch onboarding | Reset only `CleanMac.onboardingCompleted`, review four pages, finish, and relaunch | Onboarding content, persistence, language, appearance, permission guidance, or launch routing changes | First launch shows Welcome in system appearance; Back/Next/Skip work; settings open only explicitly; completion switches to Dashboard; relaunch skips onboarding | Inspect the accessibility tree and screenshots, then verify the single defaults key; leave it absent for the user's next launch | | About window | Open the app menu item, switch RU/EN and light/dark, then inspect the Window menu | About scene, metadata, localization, theme, or links change | One fixed-size About window shows the active language/theme, accurate bundle version/build, and GitHub/Releases/License links | Inspect the accessibility tree and capture a screenshot without activating external links | | Core package | `swift test --package-path CleanMacCore` | Core model, scanner, or package changes | All tests pass | Run `cd CleanMacCore && swift test` | +| Developer cleanup | Exact-root temporary fixtures plus read-only Scan review | Developer package, IDE, AI-tool, Xcode, Simulator, or Archive categories change | Only listed cache/temp roots produce results; settings/extensions/projects/sessions/history/memory stay excluded; recent Simulator data is skipped; Archives remain review-only and non-default | Run focused `testDeveloper*`, `testScannerFindsDeveloper*`, and `testXcodeDeveloperStorage*` tests; inspect Scan without starting cleanup | | Persistent cleanup history | Temporary JSON/history fixtures, multi-window snapshots, and injected restore move handlers | History storage, migration, or restore validation changes | Round-trip/status/cap/merge tests pass; corrupt, oversized, outside-Trash, symlink, and outside-allowlist records never reach the move handler; default restore walks path components without following symlinks and performs descriptor-relative exclusive rename | Run focused `testCleanupHistory*`, `testPersistedCleanupHistory*`, and `testSecureRestoreMove*` SwiftPM tests; inspect Results without triggering cleanup | | Application removal | Temporary fake `.app` fixtures with an injected Trash handler plus read-only UI review | Application scanner, removal policy, multi-selection, or Applications UI changes | App target is moved first; failed app move leaves leftovers untouched; outside/forged paths are rejected; multiple checkboxes and per-app leftovers stay isolated; no real app is removed | Run the focused `testApplication*` SwiftPM tests and inspect the batch confirmation without accepting it | +| Disk analysis | Temporary folder fixtures plus a read-only live source scan | Disk analyzer, map, large-file review, folder source, progress UI, hover behavior, or Finder/Open actions | Tree totals and large-file thresholds are correct; symlink traversal is skipped; cancellation works; root branches stay visible after the deep-node cap; no initial file selection or cleanup path exists; whole-disk mode starts at `/` with no path exclusions; scan progress uses the custom animation and a hovered sector enlarges with a localized GB tooltip | Run `DiskAnalyzerTests`, inspect Home/Downloads first, then use a cancellable `/` scan and confirm protected paths appear as issues rather than privilege escalation | +| Duplicate finder | Temporary exact-content, hard-link, changed-file, outside-root, and large-file fixtures plus a read-only live UI review | Duplicate pipeline, grouping, selection, slow mode, or Trash planning/execution changes | Only size and partial-hash survivors receive a full SHA-256; hashing concurrency stays bounded; hard links do not inflate savings; one original is unselectable; copies start unselected; standard mode reports files over 500 MiB and slow mode includes them; only unchanged validated copies reach the injected Trash handler | Run `DuplicateFinderTests`; inspect the Russian duplicate screen without scanning or moving real user files; never accept a cleanup confirmation during automated review | | Release package | `./script/package_release.sh` | Packaging, release, or CI artifact changes | `dist/*.zip` and `.sha256` are created and checksum passes | Inspect `build/XcodeData/Build/Products/Release` | | Privacy usage and entitlements | Extract `dist/*.zip` to a temporary directory; inspect its `Info.plist` and run `codesign -d --entitlements :-` on the extracted app | Permission, hardened runtime, or Apple Events changes | Usage description is present, required entitlement values are `true`, and strict signature verification passes | Inspect `dist/CleanMac.app` immediately after clearing FinderInfo added by File Provider | | CI | GitHub Actions run | After pushed app/build changes | Test, Debug build, and release artifact jobs are green | Inspect failing job logs and reproduce locally |