diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ec7ccd..2ed2fa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: CODE_SIGN_IDENTITY="" release-artifact: - name: Build unsigned release zip + name: Build unsigned release package runs-on: macos-26 timeout-minutes: 30 needs: test @@ -73,6 +73,7 @@ jobs: with: name: CleanMac-unsigned-${{ github.sha }} path: | + dist/*.dmg dist/*.zip dist/*.sha256 if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e77654..1454e8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,13 +69,15 @@ jobs: if gh release view "$tag_name" >/dev/null 2>&1; then echo "Release $tag_name already exists; replacing matching assets." gh release upload "$tag_name" \ + dist/*.dmg \ dist/*.zip \ dist/*.sha256 \ --clobber else gh release create "$tag_name" \ + dist/*.dmg \ dist/*.zip \ dist/*.sha256 \ --title "CleanMac $tag_name" \ - --notes "Unsigned CleanMac build. Download the zip and verify it with the attached sha256 file." + --notes "Unsigned CleanMac build. Open the DMG and drag CleanMac to Applications, or use the ZIP archive. Verify either download with its attached SHA-256 file." fi diff --git a/CleanMac/Models/CleanMacModels.swift b/CleanMac/Models/CleanMacModels.swift index 81e2c10..ebf8539 100644 --- a/CleanMac/Models/CleanMacModels.swift +++ b/CleanMac/Models/CleanMacModels.swift @@ -6,6 +6,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case scan case results case diskAnalysis + case systemMaintenance case duplicates case shredder case applications @@ -19,6 +20,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .scan: L.t("section.scan") case .results: L.t("section.results") case .diskAnalysis: L.t("section.diskAnalysis") + case .systemMaintenance: L.t("section.system") case .duplicates: L.t("section.duplicates") case .shredder: L.t("section.shredder") case .applications: L.t("section.applications") @@ -32,9 +34,10 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .scan: "magnifyingglass" case .results: "checklist" case .diskAnalysis: "chart.pie.fill" + case .systemMaintenance: "memorychip" case .duplicates: "square.on.square" - case .shredder: "terminal.fill" - case .applications: "app.badge.checkmark" + case .shredder: "scissors" + case .applications: "square.stack.3d.up.fill" case .settings: "gearshape" } } @@ -121,6 +124,7 @@ extension CleanupScanReason { 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 .staleCodexRuntimeInstaller: L.t("results.reason.staleCodexRuntimeInstaller.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") @@ -145,6 +149,7 @@ extension CleanupScanReason { 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 .staleCodexRuntimeInstaller: L.t("results.reason.staleCodexRuntimeInstaller.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") @@ -169,6 +174,7 @@ extension CleanupScanReason { case .developerPackageCache: "shippingbox.and.arrow.backward" case .developerIDECache: "curlybraces.square" case .developerAITemporaryFile: "sparkles.rectangle.stack" + case .staleCodexRuntimeInstaller: "shippingbox.and.arrow.backward.fill" case .staleLog: "doc.text.magnifyingglass" case .rotatedLog: "arrow.triangle.2.circlepath" case .staleTemporary: "clock.arrow.circlepath" @@ -365,6 +371,15 @@ enum CleanMacCatalog { risk: .safe, isDefaultSelected: true ), + CleanupArea( + category: .staleCodexRuntimeInstallers, + title: L.t("area.staleCodexRuntimes.title"), + detail: L.t("area.staleCodexRuntimes.detail"), + pathHint: "~/.cache/codex-runtimes/codex-runtime-install-*", + systemImage: "shippingbox.and.arrow.backward.fill", + risk: .review, + isDefaultSelected: true + ), CleanupArea( category: .logs, title: L.t("area.logs.title"), diff --git a/CleanMac/Support/CleanMacPreferences.swift b/CleanMac/Support/CleanMacPreferences.swift index 0bf6232..7c8d414 100644 --- a/CleanMac/Support/CleanMacPreferences.swift +++ b/CleanMac/Support/CleanMacPreferences.swift @@ -4,6 +4,7 @@ import Foundation enum CleanMacPreferenceKeys { static let onboardingCompleted = "CleanMac.onboardingCompleted" static let selectedAreaIDs = "CleanMac.selectedAreaIDs" + static let selectedAreaSchemaVersion = "CleanMac.selectedAreaSchemaVersion" static let lastScanItemCount = "CleanMac.lastScanItemCount" static let lastScanBytes = "CleanMac.lastScanBytes" static let lastScanTimestamp = "CleanMac.lastScanTimestamp" @@ -57,6 +58,8 @@ enum CleanMacAutoScanFrequency: String, CaseIterable, Identifiable { } enum CleanMacScanPreferences { + private static let currentAreaSchemaVersion = 1 + static var defaultSelectedAreaIDs: Set { Set(CleanMacCatalog.cleanupAreas.filter(\.isDefaultSelected).map(\.id)) } @@ -68,16 +71,24 @@ enum CleanMacScanPreferences { static func selectedAreaIDs(defaults: UserDefaults = .standard) -> Set { let rawValue = defaults.string(forKey: CleanMacPreferenceKeys.selectedAreaIDs) guard let rawValue else { + defaults.set(currentAreaSchemaVersion, forKey: CleanMacPreferenceKeys.selectedAreaSchemaVersion) return defaultSelectedAreaIDs } guard !rawValue.isEmpty else { + defaults.set(currentAreaSchemaVersion, forKey: CleanMacPreferenceKeys.selectedAreaSchemaVersion) return [] } let validIDs = Set(CleanMacCatalog.cleanupAreas.map(\.id)) - let decodedIDs = Set(rawValue.split(separator: ",").map(String.init)) + var decodedIDs = Set(rawValue.split(separator: ",").map(String.init)) .intersection(validIDs) + if defaults.integer(forKey: CleanMacPreferenceKeys.selectedAreaSchemaVersion) < currentAreaSchemaVersion { + decodedIDs.insert(CleanupCategory.staleCodexRuntimeInstallers.rawValue) + defaults.set(encodeAreaIDs(decodedIDs), forKey: CleanMacPreferenceKeys.selectedAreaIDs) + defaults.set(currentAreaSchemaVersion, forKey: CleanMacPreferenceKeys.selectedAreaSchemaVersion) + } + return decodedIDs.isEmpty ? defaultSelectedAreaIDs : decodedIDs } @@ -90,6 +101,7 @@ enum CleanMacScanPreferences { static func storeSelectedAreaIDs(_ areaIDs: Set, defaults: UserDefaults = .standard) { defaults.set(encodeAreaIDs(areaIDs), forKey: CleanMacPreferenceKeys.selectedAreaIDs) + defaults.set(currentAreaSchemaVersion, forKey: CleanMacPreferenceKeys.selectedAreaSchemaVersion) } static func storeLastScan(_ report: CleanupScanReport, source: CleanMacScanSource, defaults: UserDefaults = .standard) { diff --git a/CleanMac/Support/StatusSystemMetrics.swift b/CleanMac/Support/StatusSystemMetrics.swift index 8fbf281..4e4cafa 100644 --- a/CleanMac/Support/StatusSystemMetrics.swift +++ b/CleanMac/Support/StatusSystemMetrics.swift @@ -5,19 +5,25 @@ import CleanMacCore struct StatusSystemSnapshot: Equatable { let cpuFraction: Double - let memoryFraction: Double - let memoryUsedBytes: Int64 + let memory: StatusMemorySnapshot let disk: StatusDiskSnapshot let battery: StatusBatterySnapshot? let downloadBytesPerSecond: Int64 let uploadBytesPerSecond: Int64 let uptime: TimeInterval + var memoryFraction: Double { + memory.fraction + } + + var memoryUsedBytes: Int64 { + memory.usedBytes + } + static var initial: StatusSystemSnapshot { StatusSystemSnapshot( cpuFraction: 0, - memoryFraction: 0, - memoryUsedBytes: 0, + memory: .unavailable, disk: .current(), battery: StatusBatterySnapshot.current(), downloadBytesPerSecond: 0, @@ -27,6 +33,57 @@ struct StatusSystemSnapshot: Equatable { } } +struct StatusMemorySnapshot: Equatable, Sendable { + let fraction: Double + let usedBytes: Int64 + let totalBytes: Int64 + + var freeBytes: Int64 { + max(totalBytes - usedBytes, 0) + } + + nonisolated static var unavailable: StatusMemorySnapshot { + StatusMemorySnapshot(fraction: 0, usedBytes: 0, totalBytes: 0) + } + + nonisolated static func current() -> StatusMemorySnapshot { + 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 .unavailable + } + + var pageSize: vm_size_t = 0 + host_page_size(mach_host_self(), &pageSize) + let pageBytes = UInt64(pageSize) + guard pageBytes > 0 else { + return .unavailable + } + + let usedPages = UInt64(statistics.active_count) + + UInt64(statistics.wire_count) + + UInt64(statistics.compressor_page_count) + let cappedPages = min(usedPages, totalBytes / pageBytes) + let usedBytes = cappedPages * pageBytes + + return StatusMemorySnapshot( + fraction: min(max(Double(usedBytes) / Double(totalBytes), 0), 1), + usedBytes: Int64(clamping: usedBytes), + totalBytes: Int64(clamping: totalBytes) + ) + } +} + struct StatusDiskSnapshot: Equatable { let volumeName: String? let totalBytes: Int64 @@ -152,15 +209,14 @@ struct StatusSystemSampler { let cpuFraction = cpuUsage(current: currentCPU, previous: previousCPU) previousCPU = currentCPU - let memory = readMemory() + let memory = StatusMemorySnapshot.current() 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, + memory: memory, disk: .current(), battery: StatusBatterySnapshot.current(), downloadBytesPerSecond: networkRates.received, @@ -207,36 +263,6 @@ struct StatusSystemSampler { 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 { diff --git a/CleanMac/Support/SystemMaintenanceService.swift b/CleanMac/Support/SystemMaintenanceService.swift new file mode 100644 index 0000000..089a723 --- /dev/null +++ b/CleanMac/Support/SystemMaintenanceService.swift @@ -0,0 +1,196 @@ +import Foundation + +enum SystemMaintenanceTask: String, CaseIterable, Hashable, Identifiable, Sendable { + case memory + case dnsCache + + var id: String { rawValue } +} + +enum SystemMaintenanceReportStatus: String, Sendable { + case success + case partial + case failed + case unavailable +} + +struct SystemMaintenanceCommandResult: Identifiable, Sendable { + let id: String + let commandLine: String + let exitCode: Int32? + let output: String + let errorOutput: String + let usedAdministratorPrivileges: Bool + + nonisolated var succeeded: Bool { + exitCode == 0 + } + + nonisolated var isUnavailable: Bool { + exitCode == nil + } +} + +struct SystemMaintenanceReport: Identifiable, Sendable { + let id: String + let task: SystemMaintenanceTask + let status: SystemMaintenanceReportStatus + let commandResults: [SystemMaintenanceCommandResult] + let memoryBefore: StatusMemorySnapshot? + let memoryAfter: StatusMemorySnapshot? + let completedAt: Date +} + +struct SystemMaintenanceService: Sendable { + private struct Command: Sendable { + let displayCommandLine: String + let administratorScript: String + let requiredExecutablePaths: [String] + + nonisolated var commandLine: String { + displayCommandLine + } + } + + nonisolated func perform(_ task: SystemMaintenanceTask) async -> SystemMaintenanceReport { + let memoryBefore = task == .memory ? StatusMemorySnapshot.current() : nil + var results: [SystemMaintenanceCommandResult] = [] + for command in commands(for: task) { + results.append(await run(command)) + } + let memoryAfter: StatusMemorySnapshot? + if task == .memory { + try? await Task.sleep(for: .milliseconds(450)) + memoryAfter = StatusMemorySnapshot.current() + } else { + memoryAfter = nil + } + + let status: SystemMaintenanceReportStatus + if results.allSatisfy(\.succeeded) { + status = .success + } else if results.allSatisfy(\.isUnavailable) { + status = .unavailable + } else if results.contains(where: \.succeeded) { + status = .partial + } else { + status = .failed + } + + return SystemMaintenanceReport( + id: UUID().uuidString, + task: task, + status: status, + commandResults: results, + memoryBefore: memoryBefore, + memoryAfter: memoryAfter, + completedAt: Date() + ) + } + + nonisolated private func commands(for task: SystemMaintenanceTask) -> [Command] { + switch task { + case .memory: + [ + Command( + displayCommandLine: "/usr/sbin/purge", + administratorScript: "/usr/sbin/purge", + requiredExecutablePaths: ["/usr/sbin/purge"] + ) + ] + case .dnsCache: + [ + Command( + displayCommandLine: "/usr/bin/dscacheutil -flushcache\n/usr/bin/killall -HUP mDNSResponder", + administratorScript: """ + /usr/bin/dscacheutil -flushcache + /usr/bin/killall -HUP mDNSResponder + """, + requiredExecutablePaths: ["/usr/bin/dscacheutil", "/usr/bin/killall"] + ) + ] + } + } + + nonisolated private func run(_ command: Command) async -> SystemMaintenanceCommandResult { + await Task.detached(priority: .userInitiated) { + Self.runSynchronously(command) + }.value + } + + nonisolated private static func runSynchronously(_ command: Command) -> SystemMaintenanceCommandResult { + let fileManager = FileManager.default + if let missingPath = command.requiredExecutablePaths.first(where: { !fileManager.isExecutableFile(atPath: $0) }) { + return SystemMaintenanceCommandResult( + id: UUID().uuidString, + commandLine: command.commandLine, + exitCode: nil, + output: "", + errorOutput: "Required command is unavailable: \(missingPath)", + usedAdministratorPrivileges: false + ) + } + + guard fileManager.isExecutableFile(atPath: "/usr/bin/osascript") else { + return SystemMaintenanceCommandResult( + id: UUID().uuidString, + commandLine: command.commandLine, + exitCode: nil, + output: "", + errorOutput: "macOS authorization runner is unavailable.", + usedAdministratorPrivileges: false + ) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", + "do shell script \(appleScriptString(command.administratorScript)) with administrator privileges" + ] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return SystemMaintenanceCommandResult( + id: UUID().uuidString, + commandLine: command.commandLine, + exitCode: nil, + output: "", + errorOutput: error.localizedDescription, + usedAdministratorPrivileges: true + ) + } + + let output = String( + data: outputPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let errorOutput = String( + data: errorPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + return SystemMaintenanceCommandResult( + id: UUID().uuidString, + commandLine: command.commandLine, + exitCode: process.terminationStatus, + output: output.trimmingCharacters(in: .whitespacesAndNewlines), + errorOutput: errorOutput.trimmingCharacters(in: .whitespacesAndNewlines), + usedAdministratorPrivileges: true + ) + } + + nonisolated private static func appleScriptString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } +} diff --git a/CleanMac/Views/ApplicationsView.swift b/CleanMac/Views/ApplicationsView.swift index b6a08ba..ca92c4e 100644 --- a/CleanMac/Views/ApplicationsView.swift +++ b/CleanMac/Views/ApplicationsView.swift @@ -12,6 +12,7 @@ struct ApplicationsView: View { @State private var isScanning = false @State private var isRemoving = false @State private var isShowingConfirmation = false + @State private var removalMode: ApplicationRemovalMode = .moveToTrash @State private var statusMessage: String? @State private var problemMessage: String? @@ -35,15 +36,13 @@ struct ApplicationsView: View { private var selectedLeftoverCount: Int { selectedApplications.reduce(0) { count, application in - count + application.leftovers.filter { - selectedLeftoverIDsByApplication[application.id, default: []].contains($0.id) - }.count + count + application.leftovers.filter { effectiveLeftoverIDs(for: application).contains($0.id) }.count } } private var selectedRemovalSize: Int64 { selectedApplications.reduce(0) { total, application in - let selectedLeftoverIDs = selectedLeftoverIDsByApplication[application.id] ?? [] + let selectedLeftoverIDs = effectiveLeftoverIDs(for: application) let leftoverSize = application.leftovers .filter { selectedLeftoverIDs.contains($0.id) } .reduce(0) { $0 + $1.sizeBytes } @@ -52,9 +51,28 @@ struct ApplicationsView: View { } private var removalButtonTitle: String { - selectedApplications.count == 1 - ? L.t("applications.remove.button") - : L.f("applications.remove.multiple", selectedApplications.count) + switch removalMode { + case .moveToTrash: + selectedApplications.count == 1 + ? L.t("applications.remove.button") + : L.f("applications.remove.multiple", selectedApplications.count) + case .deletePermanently: + selectedApplications.count == 1 + ? L.t("applications.remove.permanent.button") + : L.f("applications.remove.permanent.multiple", selectedApplications.count) + } + } + + private var confirmationTitle: String { + removalMode == .deletePermanently + ? L.t("applications.confirm.permanent.title") + : L.t("applications.confirm.title") + } + + private var confirmationMessageKey: String { + removalMode == .deletePermanently + ? "applications.confirm.permanent.message" + : "applications.confirm.message" } var body: some View { @@ -103,14 +121,14 @@ struct ApplicationsView: View { .task { await refreshApplications() } - .alert(L.t("applications.confirm.title"), isPresented: $isShowingConfirmation) { + .alert(confirmationTitle, isPresented: $isShowingConfirmation) { Button(L.t("button.cancel"), role: .cancel) {} Button(removalButtonTitle, role: .destructive) { removeSelectedApplications() } } message: { Text(L.f( - "applications.confirm.message", + confirmationMessageKey, selectedApplications.count, selectedLeftoverCount, CleanMacFormatters.bytes(selectedRemovalSize) @@ -168,10 +186,13 @@ struct ApplicationsView: View { Divider() if isScanning { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) + VStack(spacing: 12) { + ModernScanProgressIndicator( + systemImage: "app.badge", + accessibilityLabel: L.t("applications.scanning") + ) Text(L.t("applications.scanning")) + .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 180) @@ -307,10 +328,31 @@ struct ApplicationsView: View { Divider() + VStack(alignment: .leading, spacing: 8) { + Text(L.t("applications.mode.title")) + .font(.headline) + + Picker(L.t("applications.mode.title"), selection: $removalMode) { + ForEach(ApplicationRemovalMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(isRemoving) + + Label(removalMode.detail, systemImage: removalMode.systemImage) + .font(.caption) + .foregroundStyle(removalMode == .deletePermanently ? .red : .secondary) + } + + Divider() + VStack(alignment: .leading, spacing: 5) { Text(L.t("applications.leftovers.title")) .font(.headline) - Text(L.t("applications.leftovers.message")) + Text(removalMode == .deletePermanently + ? L.t("applications.leftovers.permanent.message") + : L.t("applications.leftovers.message")) .font(.subheadline) .foregroundStyle(.secondary) } @@ -349,7 +391,7 @@ struct ApplicationsView: View { } } .toggleStyle(.checkbox) - .disabled(isRemoving) + .disabled(isRemoving || removalMode == .deletePermanently) } } } @@ -365,9 +407,11 @@ struct ApplicationsView: View { CleanMacFormatters.bytes(selectedRemovalSize) )) .font(.subheadline.weight(.medium)) - Text(L.t("applications.removal.trash")) + Text(removalMode == .deletePermanently + ? L.t("applications.removal.permanent") + : L.t("applications.removal.trash")) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(removalMode == .deletePermanently ? .red : .secondary) } Spacer() @@ -379,7 +423,7 @@ struct ApplicationsView: View { ProgressView() .controlSize(.small) } else { - Label(removalButtonTitle, systemImage: "trash") + Label(removalButtonTitle, systemImage: removalMode.systemImage) } } .buttonStyle(.borderedProminent) @@ -420,8 +464,16 @@ struct ApplicationsView: View { private func leftoverBinding(for id: String, applicationID: String) -> Binding { Binding( - get: { selectedLeftoverIDsByApplication[applicationID, default: []].contains(id) }, + get: { + if removalMode == .deletePermanently { + return true + } + return selectedLeftoverIDsByApplication[applicationID, default: []].contains(id) + }, set: { isSelected in + guard removalMode == .moveToTrash else { + return + } if isSelected { selectedApplicationIDs.insert(applicationID) selectedLeftoverIDsByApplication[applicationID, default: []].insert(id) @@ -432,6 +484,15 @@ struct ApplicationsView: View { ) } + private func effectiveLeftoverIDs(for application: InstalledApplication) -> Set { + switch removalMode { + case .moveToTrash: + selectedLeftoverIDsByApplication[application.id] ?? [] + case .deletePermanently: + Set(application.leftovers.map(\.id)) + } + } + @MainActor private func refreshApplications() async { guard !isScanning, !isRemoving else { @@ -478,7 +539,12 @@ struct ApplicationsView: View { statusMessage = nil problemMessage = nil - let selectedLeftoverIDs = selectedLeftoverIDsByApplication + let removalMode = removalMode + let selectedLeftoverIDs = Dictionary( + uniqueKeysWithValues: removalApplications.map { application in + (application.id, effectiveLeftoverIDs(for: application)) + } + ) let excludedBundleIdentifiers = Set([Bundle.main.bundleIdentifier].compactMap { $0 }) let excludedApplicationPaths = Set([Bundle.main.bundleURL.path]) @@ -492,7 +558,7 @@ struct ApplicationsView: View { for: application, selectedLeftoverIDs: selectedLeftoverIDs[application.id] ?? [] ) - return (application.id, ApplicationRemovalExecutor().execute(plan: plan)) + return (application.id, ApplicationRemovalExecutor().execute(plan: plan, mode: removalMode)) } }.value @@ -509,7 +575,9 @@ struct ApplicationsView: View { if !movedApplicationIDs.isEmpty { statusMessage = L.f( - "applications.removal.success", + removalMode == .deletePermanently + ? "applications.removal.permanent.success" + : "applications.removal.success", movedApplicationIDs.count, movedLeftoverCount, CleanMacFormatters.bytes(totalMovedBytes) @@ -542,6 +610,14 @@ private extension ApplicationLeftoverKind { case .preferences: L.t("applications.leftover.preferences") case .savedApplicationState: L.t("applications.leftover.savedState") case .logs: L.t("applications.leftover.logs") + case .applicationSupport: L.t("applications.leftover.applicationSupport") + case .container: L.t("applications.leftover.container") + case .groupContainer: L.t("applications.leftover.groupContainer") + case .httpStorage: L.t("applications.leftover.httpStorage") + case .webKit: L.t("applications.leftover.webKit") + case .cookies: L.t("applications.leftover.cookies") + case .applicationScripts: L.t("applications.leftover.applicationScripts") + case .launchAgent: L.t("applications.leftover.launchAgent") } } @@ -551,6 +627,37 @@ private extension ApplicationLeftoverKind { case .preferences: "slider.horizontal.3" case .savedApplicationState: "macwindow" case .logs: "doc.text" + case .applicationSupport: "folder.badge.gearshape" + case .container: "shippingbox.circle" + case .groupContainer: "square.stack.3d.up" + case .httpStorage: "network" + case .webKit: "safari" + case .cookies: "circle.hexagongrid" + case .applicationScripts: "applescript" + case .launchAgent: "gearshape.arrow.triangle.2.circlepath" + } + } +} + +private extension ApplicationRemovalMode { + var title: String { + switch self { + case .moveToTrash: L.t("applications.mode.trash") + case .deletePermanently: L.t("applications.mode.permanent") + } + } + + var detail: String { + switch self { + case .moveToTrash: L.t("applications.mode.trash.detail") + case .deletePermanently: L.t("applications.mode.permanent.detail") + } + } + + var systemImage: String { + switch self { + case .moveToTrash: "trash" + case .deletePermanently: "trash.slash" } } } diff --git a/CleanMac/Views/DiskAnalysisProgressIndicator.swift b/CleanMac/Views/DiskAnalysisProgressIndicator.swift index 03413bd..a1d5d4d 100644 --- a/CleanMac/Views/DiskAnalysisProgressIndicator.swift +++ b/CleanMac/Views/DiskAnalysisProgressIndicator.swift @@ -1,40 +1,138 @@ import SwiftUI -struct DiskAnalysisProgressIndicator: View { +struct ModernScanProgressIndicator: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + let systemImage: String + let accessibilityLabel: String + let progress: Double? + let size: CGFloat + + @State private var isRotating = false + @State private var isPulsing = false + + init( + systemImage: String, + accessibilityLabel: String, + progress: Double? = nil, + size: CGFloat = 58 + ) { + self.systemImage = systemImage + self.accessibilityLabel = accessibilityLabel + self.progress = progress + self.size = size + } + + private var clampedProgress: Double? { + progress.map { min(max($0, 0.035), 1) } + } + + private var lineWidth: CGFloat { + max(4, size * 0.075) + } + var body: some View { ZStack { Circle() .fill( RadialGradient( colors: [ - Color.accentColor.opacity(0.2), - Color.cyan.opacity(0.08), + Color.accentColor.opacity(0.22), + Color.cyan.opacity(0.10), .clear ], center: .center, - startRadius: 2, - endRadius: 30 + startRadius: 1, + endRadius: size * 0.52 ) ) + .scaleEffect(isPulsing ? 1.04 : 0.94) + .animation( + reduceMotion + ? nil + : .easeInOut(duration: 1.3).repeatForever(autoreverses: true), + value: isPulsing + ) Circle() - .stroke(Color.accentColor.opacity(0.18), lineWidth: 5) - - if reduceMotion { - Image(systemName: "chart.pie.fill") - .font(.system(size: 16, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.tint) - } else { - ProgressView() - .controlSize(.regular) - .tint(.accentColor) + .stroke(Color.accentColor.opacity(0.16), lineWidth: lineWidth) + .padding(size * 0.10) + + if let clampedProgress { + Circle() + .trim(from: 0, to: clampedProgress) + .stroke( + LinearGradient( + colors: [.blue, .cyan, .mint], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .padding(size * 0.10) + .rotationEffect(.degrees(-90)) + .animation(.easeOut(duration: 0.24), value: clampedProgress) } + + Circle() + .trim(from: 0.04, to: 0.66) + .stroke( + AngularGradient( + colors: [.blue.opacity(0.24), .cyan, .mint, .blue.opacity(0.24)], + center: .center + ), + style: StrokeStyle(lineWidth: max(2.5, size * 0.052), lineCap: .round) + ) + .padding(size * 0.19) + .rotationEffect(.degrees(isRotating ? 360 : 0)) + .shadow(color: Color.cyan.opacity(0.28), radius: size * 0.08) + .animation( + reduceMotion + ? nil + : .linear(duration: 2.2).repeatForever(autoreverses: false), + value: isRotating + ) + + Circle() + .trim(from: 0.08, to: 0.31) + .stroke( + Color.primary.opacity(0.58), + style: StrokeStyle(lineWidth: max(1.5, size * 0.028), lineCap: .round) + ) + .padding(size * 0.27) + .rotationEffect(.degrees(isRotating ? -360 : 0)) + .animation( + reduceMotion + ? nil + : .linear(duration: 1.65).repeatForever(autoreverses: false), + value: isRotating + ) + + Image(systemName: systemImage) + .font(.system(size: size * 0.27, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + .scaleEffect(isPulsing ? 1.04 : 0.96) + .animation( + reduceMotion + ? nil + : .easeInOut(duration: 1.3).repeatForever(autoreverses: true), + value: isPulsing + ) } - .frame(width: 58, height: 58) + .frame(width: size, height: size) .accessibilityElement(children: .ignore) - .accessibilityLabel(L.t("disk.scanning.title")) + .accessibilityLabel(accessibilityLabel) + .onAppear { + updateAnimationState() + } + .onChange(of: reduceMotion) { _, _ in + updateAnimationState() + } + } + + private func updateAnimationState() { + isRotating = !reduceMotion + isPulsing = !reduceMotion } } diff --git a/CleanMac/Views/DiskAnalysisView.swift b/CleanMac/Views/DiskAnalysisView.swift index 420f128..31c191b 100644 --- a/CleanMac/Views/DiskAnalysisView.swift +++ b/CleanMac/Views/DiskAnalysisView.swift @@ -219,7 +219,10 @@ struct DiskAnalysisView: View { private var scanningPanel: some View { InfoPanel { HStack(spacing: 14) { - DiskAnalysisProgressIndicator() + ModernScanProgressIndicator( + systemImage: "chart.pie.fill", + accessibilityLabel: L.t("disk.scanning.title") + ) VStack(alignment: .leading, spacing: 5) { Text(L.t("disk.scanning.title")) diff --git a/CleanMac/Views/DuplicateFinderView.swift b/CleanMac/Views/DuplicateFinderView.swift index 69fb9e8..14e0d84 100644 --- a/CleanMac/Views/DuplicateFinderView.swift +++ b/CleanMac/Views/DuplicateFinderView.swift @@ -210,7 +210,10 @@ struct DuplicateFinderView: View { private var scanningPanel: some View { InfoPanel { HStack(spacing: 14) { - DiskAnalysisProgressIndicator() + ModernScanProgressIndicator( + systemImage: "doc.on.doc.fill", + accessibilityLabel: progress?.phase.localizedTitle ?? L.t("duplicates.phase.enumerating") + ) VStack(alignment: .leading, spacing: 5) { Text(progress?.phase.localizedTitle ?? L.t("duplicates.phase.enumerating")) diff --git a/CleanMac/Views/MainWindowView.swift b/CleanMac/Views/MainWindowView.swift index d024f06..919bc7a 100644 --- a/CleanMac/Views/MainWindowView.swift +++ b/CleanMac/Views/MainWindowView.swift @@ -46,6 +46,7 @@ struct MainWindowView: View { ToolbarItemGroup { if selectedSection != .diskAnalysis, selectedSection != .duplicates, + selectedSection != .systemMaintenance, selectedSection != .shredder { Button { runScan() @@ -132,6 +133,8 @@ struct MainWindowView: View { ) case .diskAnalysis: DiskAnalysisView() + case .systemMaintenance: + SystemMaintenanceView() case .duplicates: DuplicateFinderView() case .shredder: diff --git a/CleanMac/Views/ResultsView.swift b/CleanMac/Views/ResultsView.swift index fdc1a57..0339a4b 100644 --- a/CleanMac/Views/ResultsView.swift +++ b/CleanMac/Views/ResultsView.swift @@ -31,6 +31,14 @@ struct ResultsView: View { selectedResults.reduce(0) { $0 + $1.sizeBytes } } + private var containsStaleCodexRuntimeInstallers: Bool { + results.contains { $0.category == .staleCodexRuntimeInstallers } + } + + private var selectedStaleCodexRuntimeInstallers: Bool { + selectedResults.contains { $0.category == .staleCodexRuntimeInstallers } + } + private var visibleResults: [ScanResult] { guard let selectedCategory else { return results @@ -132,6 +140,15 @@ struct ResultsView: View { statusBanners + if containsStaleCodexRuntimeInstallers { + StatusBanner( + title: L.t("results.codexRuntimes.title"), + message: L.t("results.codexRuntimes.message"), + systemImage: "shippingbox.and.arrow.backward.fill", + tint: .orange + ) + } + if safeModeEnabled, results.contains(where: { $0.risk == .review }) { StatusBanner( title: L.t("results.safeMode.title"), @@ -452,7 +469,9 @@ struct ResultsView: View { private var cleanupConfirmationMessage: String { L.f( - "cleanup.confirm.message", + selectedStaleCodexRuntimeInstallers + ? "cleanup.confirm.codexRuntime.message" + : "cleanup.confirm.message", selectedResults.count, CleanMacFormatters.bytes(selectedSizeBytes) ) diff --git a/CleanMac/Views/ScanActivityView.swift b/CleanMac/Views/ScanActivityView.swift index 9ef0712..41f5714 100644 --- a/CleanMac/Views/ScanActivityView.swift +++ b/CleanMac/Views/ScanActivityView.swift @@ -29,7 +29,7 @@ struct ScanActivityView: View { private var horizontalLayout: some View { HStack(alignment: .center, spacing: 18) { - ScanOrbitalIndicator(progress: progressFraction) + activityIndicator content .frame(maxWidth: .infinity, alignment: .leading) @@ -43,7 +43,7 @@ struct ScanActivityView: View { private var verticalLayout: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 14) { - ScanOrbitalIndicator(progress: progressFraction) + activityIndicator header } @@ -63,6 +63,15 @@ struct ScanActivityView: View { } } + private var activityIndicator: some View { + ModernScanProgressIndicator( + systemImage: "magnifyingglass", + accessibilityLabel: L.t("scan.animation.title"), + progress: progressFraction, + size: 82 + ) + } + private var header: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { @@ -87,29 +96,10 @@ struct ScanActivityView: View { } private var progressTrack: some View { - GeometryReader { proxy in - let trackWidth = proxy.size.width - let filledWidth = max(8, trackWidth * progressFraction) - - ZStack(alignment: .leading) { - Capsule() - .fill(.quaternary) - - Capsule() - .fill( - LinearGradient( - colors: [.blue, .cyan, .green.opacity(0.82)], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: filledWidth) - .clipShape(Capsule()) - .animation(.easeOut(duration: 0.2), value: progressFraction) - } - } - .frame(height: 8) - .accessibilityLabel(L.f("scan.animation.percent", progressPercent)) + AnimatedScanProgressTrack( + progress: progressFraction, + accessibilityLabel: L.f("scan.animation.percent", progressPercent) + ) } private var scanMetrics: some View { @@ -255,67 +245,111 @@ struct ScanActivityView: View { let name = URL(fileURLWithPath: path).lastPathComponent return name.isEmpty ? path : name } - } -private struct ScanOrbitalIndicator: View { +private struct AnimatedScanProgressTrack: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let progress: Double + let accessibilityLabel: String + + @State private var isShimmering = false var body: some View { - ZStack { - Circle() - .fill(.blue.opacity(0.10)) - .frame(width: 76, height: 76) - - Circle() - .stroke(.blue.opacity(0.13), lineWidth: 12) - .frame(width: 60, height: 60) - - Circle() - .trim(from: 0, to: max(0.04, progress)) - .stroke( - LinearGradient( - colors: [.blue, .cyan, .green.opacity(0.82)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - style: StrokeStyle(lineWidth: 5, lineCap: .round) - ) - .frame(width: 64, height: 64) - .rotationEffect(.degrees(-90)) - .animation(.easeOut(duration: 0.2), value: progress) + GeometryReader { proxy in + let trackWidth = proxy.size.width + let filledWidth = max(8, trackWidth * min(max(progress, 0), 1)) - ProgressView() - .controlSize(.small) - .tint(.blue) + ZStack(alignment: .leading) { + Capsule() + .fill(.quaternary) + + Capsule() + .fill( + LinearGradient( + colors: [.blue, .cyan, .mint], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: filledWidth) + .overlay(alignment: .leading) { + if !reduceMotion { + Capsule() + .fill( + LinearGradient( + colors: [.clear, .white.opacity(0.46), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 48) + .offset(x: isShimmering ? filledWidth : -48) + .animation( + .linear(duration: 1.5).repeatForever(autoreverses: false), + value: isShimmering + ) + } + } + .clipShape(Capsule()) + .animation(.easeOut(duration: 0.24), value: progress) + } + } + .frame(height: 8) + .accessibilityLabel(accessibilityLabel) + .onAppear { + isShimmering = !reduceMotion + } + .onChange(of: reduceMotion) { _, _ in + isShimmering = !reduceMotion } - .frame(width: 82, height: 82) - .accessibilityHidden(true) } } private struct ScanSignalBars: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let progress: Double private let barCount = 5 + @State private var isAnimating = false + var body: some View { HStack(alignment: .bottom, spacing: 5) { ForEach(0.. CGFloat { let clampedProgress = min(max(progress, 0.08), 1) let step = Double(index + 1) / Double(barCount) - return CGFloat(12 + 28 * clampedProgress * step) + let base = CGFloat(12 + 18 * clampedProgress * step) + guard !reduceMotion else { + return base + } + let amplitude = CGFloat(7 + index * 2) + return isAnimating ? min(44, base + amplitude) : max(10, base - amplitude * 0.45) } private func barColor(for index: Int) -> Color { diff --git a/CleanMac/Views/ShredderAnimationView.swift b/CleanMac/Views/ShredderAnimationView.swift new file mode 100644 index 0000000..58f617f --- /dev/null +++ b/CleanMac/Views/ShredderAnimationView.swift @@ -0,0 +1,469 @@ +import AppKit +import CleanMacCore +import QuickLookThumbnailing +import SwiftUI + +enum ShredderAnimationPhase: Equatable { + case preparing + case feeding + case overwriting + case finalizing + case success + case failure + + var isWorking: Bool { + self == .overwriting || self == .finalizing + } + + var releasesFragments: Bool { + switch self { + case .overwriting, .finalizing, .success, .failure: + true + case .preparing, .feeding: + false + } + } +} + +struct ShredderAnimationSession: Identifiable { + let id = UUID() + let candidateID: String + let fileName: String + let thumbnail: NSImage + let currentIndex: Int + let totalCount: Int + var phase: ShredderAnimationPhase + var progress: Double +} + +@MainActor +enum ShredderPreviewService { + static func preview(for candidate: SecureDeletionCandidate) async -> NSImage { + let url = URL(fileURLWithPath: candidate.path) + let fallback = NSWorkspace.shared.icon(forFile: candidate.path) + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: CGSize(width: 480, height: 320), + scale: NSScreen.main?.backingScaleFactor ?? 2, + representationTypes: [.thumbnail, .icon] + ) + + guard let representation = try? await QLThumbnailGenerator.shared + .generateBestRepresentation(for: request) else { + return fallback + } + return representation.nsImage + } +} + +struct ShredderAnimationView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let session: ShredderAnimationSession + let palette: NeoShredderPalette + + @State private var bladesRotated = false + + private let stripCount = 20 + + var body: some View { + VStack(spacing: 14) { + statusHeader + animationStage + progressFooter + } + .padding(18) + .frame(maxWidth: .infinity) + .background( + palette.surface, + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(stageTint.opacity(0.52)) + } + .shadow(color: palette.shadow, radius: 18, y: 10) + .shadow(color: stageTint.opacity(session.phase.isWorking ? 0.24 : 0.12), radius: 22) + .task(id: session.phase) { + bladesRotated = false + guard session.phase.isWorking, !reduceMotion else { return } + await Task.yield() + withAnimation(.linear(duration: 0.72).repeatForever(autoreverses: false)) { + bladesRotated = true + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L.f( + "shredder.animation.accessibility", + session.fileName, + Int((clampedProgress * 100).rounded()) + )) + } + + private var statusHeader: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(stageTint.opacity(0.12)) + Image(systemName: phaseIcon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(stageTint) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 3) { + Text(phaseTitle) + .font(.headline.monospaced()) + .foregroundStyle(palette.textPrimary) + Text(session.fileName) + .font(.caption.monospaced()) + .foregroundStyle(palette.textMuted) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 12) + + Text(L.f( + "shredder.animation.position", + session.currentIndex, + session.totalCount + )) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(palette.textMuted) + } + } + + private var animationStage: some View { + GeometryReader { proxy in + let stageWidth = proxy.size.width + let previewSize = CGSize( + width: min(188, max(148, stageWidth * 0.28)), + height: 116 + ) + let machineWidth = min(390, max(280, stageWidth * 0.58)) + + ZStack { + stageBackdrop + fragmentBin(width: machineWidth * 0.82) + .position(x: stageWidth / 2, y: 235) + + fragmentGroup(size: previewSize) + .position(x: stageWidth / 2, y: 116) + + filePreview(size: previewSize) + .position(x: stageWidth / 2, y: previewY) + .opacity(previewOpacity) + .scaleEffect(previewScale) + .animation(previewAnimation, value: session.phase) + + machineBody(width: machineWidth) + .position(x: stageWidth / 2, y: 92) + + outcomeMark + .position(x: stageWidth / 2, y: 178) + } + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + } + .frame(height: 276) + } + + private var stageBackdrop: some View { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(palette.inset.opacity(0.6)) + + VStack(spacing: 22) { + ForEach(0..<10, id: \.self) { _ in + Rectangle() + .fill(palette.line.opacity(0.38)) + .frame(height: 1) + } + } + + LinearGradient( + colors: [.clear, stageTint.opacity(0.06), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private func machineBody(width: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + colors: [palette.textPrimary.opacity(0.92), palette.textPrimary.opacity(0.72)], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: Color.black.opacity(0.34), radius: 12, y: 8) + + VStack(spacing: 9) { + HStack(spacing: 9) { + Image(systemName: "gearshape.2.fill") + .foregroundStyle(stageTint) + .rotationEffect(.degrees(bladesRotated ? 360 : 0)) + + Text(L.t("shredder.animation.machine")) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.84)) + + Spacer(minLength: 8) + + Circle() + .fill(stageTint) + .frame(width: 7, height: 7) + .shadow(color: stageTint, radius: session.phase.isWorking ? 7 : 2) + } + + Capsule() + .fill(Color.black.opacity(0.78)) + .frame(height: 10) + .overlay { + Capsule() + .strokeBorder(stageTint.opacity(0.52), lineWidth: 1) + } + .shadow(color: stageTint.opacity(0.3), radius: 6) + } + .padding(.horizontal, 18) + } + .frame(width: width, height: 78) + } + + private func fragmentBin(width: CGFloat) -> some View { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(palette.surface.opacity(0.5)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(palette.line) + } + + Capsule() + .fill(stageTint.opacity(0.38)) + .frame(width: width * 0.72, height: 3) + .padding(.top, 8) + } + .frame(width: width, height: 68) + } + + private func filePreview(size: CGSize) -> some View { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + + Image(nsImage: session.thumbnail) + .resizable() + .interpolation(.high) + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipped() + + LinearGradient( + colors: [.clear, Color.black.opacity(0.66)], + startPoint: .center, + endPoint: .bottom + ) + + Text(session.fileName) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.white) + .lineLimit(1) + .truncationMode(.middle) + .padding(8) + } + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(Color.white.opacity(0.68), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.25), radius: 10, y: 7) + } + + private func fragmentGroup(size: CGSize) -> some View { + HStack(spacing: 0) { + ForEach(0.. some View { + let stripWidth = size.width / CGFloat(stripCount) + let stripCenter = stripWidth * (CGFloat(index) + 0.5) + let drift = reduceMotion ? 0 : fragmentDrift(index) + let fall = reduceMotion ? 12 : fragmentFall(index) + let rotation = reduceMotion ? 0 : fragmentRotation(index) + let isReleased = session.phase.releasesFragments + let isGone = session.phase == .success + + return filePreview(size: size) + .offset(x: size.width / 2 - stripCenter) + .frame(width: stripWidth + 0.35, height: size.height) + .clipped() + .offset( + x: isReleased ? drift : 0, + y: isReleased ? fall + (isGone ? 22 : 0) : 0 + ) + .rotationEffect(.degrees(isReleased ? rotation : 0)) + .opacity(isGone ? 0 : (session.phase == .failure ? 0.48 : 0.96)) + .animation( + reduceMotion + ? .easeOut(duration: 0.18) + : .spring(response: 0.74, dampingFraction: 0.76) + .delay(Double(index) * 0.018), + value: session.phase + ) + } + + @ViewBuilder + private var outcomeMark: some View { + if session.phase == .success || session.phase == .failure { + ZStack { + Circle() + .fill(palette.surface.opacity(0.92)) + .overlay { + Circle().strokeBorder(stageTint.opacity(0.5), lineWidth: 1) + } + .shadow(color: stageTint.opacity(0.34), radius: 18) + + Image(systemName: session.phase == .success ? "checkmark" : "exclamationmark") + .font(.system(size: 25, weight: .bold)) + .foregroundStyle(stageTint) + } + .frame(width: 58, height: 58) + .transition(.scale(scale: reduceMotion ? 1 : 0.72).combined(with: .opacity)) + } + } + + private var progressFooter: some View { + VStack(spacing: 7) { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(palette.inset) + Capsule() + .fill( + LinearGradient( + colors: [stageTint.opacity(0.72), stageTint], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(0, proxy.size.width * clampedProgress)) + .shadow(color: stageTint.opacity(0.45), radius: 6) + } + } + .frame(height: 8) + .animation(.easeOut(duration: reduceMotion ? 0.05 : 0.16), value: clampedProgress) + + HStack { + Text(phaseDetail) + .font(.caption.monospaced()) + .foregroundStyle(palette.textMuted) + Spacer() + Text("\(Int((clampedProgress * 100).rounded()))%") + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundStyle(stageTint) + } + } + } + + private var previewY: CGFloat { + switch session.phase { + case .preparing: + -78 + case .feeding: + 45 + case .overwriting, .finalizing, .success, .failure: + 72 + } + } + + private var previewOpacity: Double { + switch session.phase { + case .preparing: + 0 + case .feeding: + 1 + case .overwriting, .finalizing, .success, .failure: + 0 + } + } + + private var previewScale: CGFloat { + session.phase == .preparing ? 0.94 : 1 + } + + private var previewAnimation: Animation { + reduceMotion + ? .easeOut(duration: 0.18) + : .spring(response: 0.68, dampingFraction: 0.82) + } + + private var clampedProgress: Double { + min(1, max(0, session.progress)) + } + + private var stageTint: Color { + switch session.phase { + case .failure: + palette.danger + case .success: + palette.success + default: + palette.cyan + } + } + + private var phaseIcon: String { + switch session.phase { + case .preparing: "doc.richtext" + case .feeding: "arrow.down.to.line.compact" + case .overwriting: "waveform.path.ecg" + case .finalizing: "lock.rotation" + case .success: "checkmark.circle.fill" + case .failure: "exclamationmark.triangle.fill" + } + } + + private var phaseTitle: String { + switch session.phase { + case .preparing: L.t("shredder.animation.preparing") + case .feeding: L.t("shredder.animation.feeding") + case .overwriting: L.t("shredder.animation.overwriting") + case .finalizing: L.t("shredder.animation.finalizing") + case .success: L.t("shredder.animation.success") + case .failure: L.t("shredder.animation.failure") + } + } + + private var phaseDetail: String { + switch session.phase { + case .preparing: L.t("shredder.animation.detail.preparing") + case .feeding: L.t("shredder.animation.detail.feeding") + case .overwriting: L.t("shredder.animation.detail.overwriting") + case .finalizing: L.t("shredder.animation.detail.finalizing") + case .success: L.t("shredder.animation.detail.success") + case .failure: L.t("shredder.animation.detail.failure") + } + } + + private func fragmentDrift(_ index: Int) -> CGFloat { + CGFloat((index * 37) % 19 - 9) * 1.7 + } + + private func fragmentFall(_ index: Int) -> CGFloat { + CGFloat(82 + (index * 23) % 42) + } + + private func fragmentRotation(_ index: Int) -> Double { + Double((index * 31) % 17 - 8) * 1.8 + } +} diff --git a/CleanMac/Views/ShredderView.swift b/CleanMac/Views/ShredderView.swift index 2959450..592e927 100644 --- a/CleanMac/Views/ShredderView.swift +++ b/CleanMac/Views/ShredderView.swift @@ -1,8 +1,14 @@ import CleanMacCore import SwiftUI +private enum ShredderExecutionResult: Sendable { + case success(Int64) + case failure(SecureDeletionError?) +} + struct ShredderView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var candidates: [SecureDeletionCandidate] = [] @State private var isChoosingFiles = false @@ -13,6 +19,7 @@ struct ShredderView: View { @State private var showingConfirmation = false @State private var confirmationText = "" @State private var acknowledgedLimitations = false + @State private var animationSession: ShredderAnimationSession? private var palette: NeoShredderPalette { NeoShredderPalette(colorScheme: colorScheme) @@ -33,10 +40,18 @@ struct ShredderView: View { var body: some View { PageContainer { VStack(alignment: .leading, spacing: 16) { - hackerHeader - limitationPanel - queueControls - queuePanel + if let animationSession { + ShredderAnimationView( + session: animationSession, + palette: palette + ) + .transition(.opacity.combined(with: .scale(scale: reduceMotion ? 1 : 0.98))) + } else { + hackerHeader + limitationPanel + queueControls + queuePanel + } if let statusMessage { feedbackPanel(message: statusMessage, isProblem: false) @@ -62,7 +77,7 @@ struct ShredderView: View { NeoShredderPanel(palette: palette, isGlowing: true) { ViewThatFits(in: .horizontal) { HStack(spacing: 18) { - terminalMark + shredderMark headerCopy Spacer(minLength: 12) stateStack @@ -70,7 +85,7 @@ struct ShredderView: View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 14) { - terminalMark + shredderMark headerCopy } stateStack @@ -79,7 +94,7 @@ struct ShredderView: View { } } - private var terminalMark: some View { + private var shredderMark: some View { ZStack { RoundedRectangle(cornerRadius: 15, style: .continuous) .fill(palette.inset) @@ -89,7 +104,7 @@ struct ShredderView: View { } .shadow(color: palette.glow.opacity(0.8), radius: 16) - Image(systemName: "terminal.fill") + Image(systemName: CleanMacSection.shredder.systemImage) .font(.system(size: 26, weight: .bold)) .foregroundStyle(palette.cyan) } @@ -415,28 +430,54 @@ struct ShredderView: View { statusMessage = nil problemMessage = nil - Task { + Task { @MainActor in var removedBytes: Int64 = 0 var removedIDs = Set() var failures: [String] = [] - for candidate in reviewedCandidates { - do { - let bytes = try await Task.detached(priority: .utility) { - try SecureFileShredder().shred(candidate) - }.value + for (offset, candidate) in reviewedCandidates.enumerated() { + let thumbnail = await ShredderPreviewService.preview(for: candidate) + animationSession = ShredderAnimationSession( + candidateID: candidate.id, + fileName: candidate.name, + thumbnail: thumbnail, + currentIndex: offset + 1, + totalCount: reviewedCandidates.count, + phase: .preparing, + progress: 0 + ) + + try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 140)) + animationSession?.phase = .feeding + try? await Task.sleep(for: .milliseconds(reduceMotion ? 180 : 720)) + animationSession?.phase = .overwriting + + let minimumVisualDuration = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(reduceMotion ? 180 : 1_150)) + } + let result = await shredWithProgress(candidate) + await minimumVisualDuration.value + + switch result { + case .success(let bytes): removedBytes += bytes removedIDs.insert(candidate.id) - } catch { + animationSession?.progress = 1 + animationSession?.phase = .success + try? await Task.sleep(for: .milliseconds(reduceMotion ? 240 : 760)) + case .failure(let error): failures.append(L.f( "shredder.error.file", candidate.name, - errorReason(error) + error.map(errorReason) ?? L.t("shredder.error.generic") )) + animationSession?.phase = .failure + try? await Task.sleep(for: .milliseconds(reduceMotion ? 320 : 980)) } completedCount += 1 } + animationSession = nil candidates.removeAll { removedIDs.contains($0.id) } isShredding = false statusMessage = L.f( @@ -450,6 +491,43 @@ struct ShredderView: View { } } + private func shredWithProgress( + _ candidate: SecureDeletionCandidate + ) async -> ShredderExecutionResult { + let (stream, continuation) = AsyncStream.makeStream( + of: SecureDeletionProgress.self, + bufferingPolicy: .bufferingNewest(1) + ) + let worker = Task.detached(priority: .utility) { + defer { continuation.finish() } + do { + let bytes = try SecureFileShredder().shred(candidate) { progress in + continuation.yield(progress) + } + return ShredderExecutionResult.success(bytes) + } catch { + return ShredderExecutionResult.failure(error as? SecureDeletionError) + } + } + + for await progress in stream { + guard animationSession?.candidateID == candidate.id else { continue } + switch progress.stage { + case .overwriting: + animationSession?.phase = .overwriting + animationSession?.progress = progress.fractionCompleted * 0.9 + case .finalizing: + animationSession?.phase = .finalizing + animationSession?.progress = 0.95 + case .complete: + animationSession?.phase = .finalizing + animationSession?.progress = 0.98 + } + } + + return await worker.value + } + private func errorReason(_ error: Error) -> String { guard let error = error as? SecureDeletionError else { return L.t("shredder.error.generic") @@ -561,7 +639,7 @@ private struct NeoShredderButtonStyle: ButtonStyle { } } -private struct NeoShredderPalette { +struct NeoShredderPalette { let surface: Color let inset: Color let textPrimary: Color diff --git a/CleanMac/Views/SidebarView.swift b/CleanMac/Views/SidebarView.swift index 3332219..b9776fb 100644 --- a/CleanMac/Views/SidebarView.swift +++ b/CleanMac/Views/SidebarView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct SidebarView: View { @@ -51,10 +52,7 @@ private struct SidebarSectionButton: View { var body: some View { Button(action: action) { HStack(spacing: 10) { - Image(systemName: section.systemImage) - .font(.system(size: 14, weight: isSelected ? .semibold : .medium)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(iconColor) + sectionIcon .frame(width: 18, height: 18) .scaleEffect(iconScale) @@ -90,6 +88,25 @@ private struct SidebarSectionButton: View { .animation(rowAnimation, value: isKeyboardFocused) } + @ViewBuilder + private var sectionIcon: some View { + if section == .applications { + Image(nsImage: SidebarApplicationIcon.image) + .resizable() + .renderingMode(.template) + .interpolation(.high) + .antialiased(true) + .aspectRatio(contentMode: .fit) + .foregroundStyle(iconColor) + .padding(0.25) + } else { + Image(systemName: section.systemImage) + .font(.system(size: 14, weight: isSelected ? .semibold : .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(iconColor) + } + } + private var rowBackground: some View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(backgroundColor) @@ -183,6 +200,29 @@ private struct SidebarSectionButton: View { } } +private enum SidebarApplicationIcon { + static let image: NSImage = { + let resourcePath = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/SidebarApplicationsFolder.icns" + if let source = NSImage(contentsOfFile: resourcePath), + let bitmap = source.representations + .compactMap({ $0 as? NSBitmapImageRep }) + .first(where: { $0.pixelsWide == 36 && $0.pixelsHigh == 36 }), + let cgImage = bitmap.cgImage { + let image = NSImage( + cgImage: cgImage, + size: NSSize(width: 18, height: 18) + ) + image.isTemplate = true + return image + } + + return NSImage( + systemSymbolName: "square.stack.3d.up.fill", + accessibilityDescription: L.t("section.applications") + ) ?? NSImage(size: NSSize(width: 18, height: 18)) + }() +} + private struct SidebarPressButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) private var reduceMotion diff --git a/CleanMac/Views/SystemMaintenanceView.swift b/CleanMac/Views/SystemMaintenanceView.swift new file mode 100644 index 0000000..21e79d1 --- /dev/null +++ b/CleanMac/Views/SystemMaintenanceView.swift @@ -0,0 +1,423 @@ +import SwiftUI + +struct SystemMaintenanceView: View { + @State private var activeTask: SystemMaintenanceTask? + @State private var reports: [SystemMaintenanceTask: SystemMaintenanceReport] = [:] + @State private var memorySnapshot = StatusMemorySnapshot.current() + + private let service = SystemMaintenanceService() + + var body: some View { + PageContainer { + VStack(alignment: .leading, spacing: 18) { + PageHeader( + title: L.t("system.title"), + subtitle: L.t("system.subtitle"), + systemImage: CleanMacSection.systemMaintenance.systemImage + ) + + StatusBanner( + title: L.t("system.safety.title"), + message: L.t("system.safety.message"), + systemImage: "hand.tap", + tint: .blue + ) + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 320), spacing: 14)], + alignment: .leading, + spacing: 14 + ) { + ForEach(SystemMaintenanceTask.allCases) { task in + SystemMaintenanceCard( + task: task, + isRunning: activeTask == task, + isDisabled: activeTask != nil && activeTask != task, + report: reports[task], + liveMemorySnapshot: task == .memory ? memorySnapshot : nil, + action: { + perform(task) + } + ) + } + } + + InfoPanel { + VStack(alignment: .leading, spacing: 10) { + Label(L.t("system.limits.title"), systemImage: "info.circle") + .font(.headline) + Text(L.t("system.limits.message")) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .task { + await refreshMemorySnapshot() + } + } + + private func perform(_ task: SystemMaintenanceTask) { + guard activeTask == nil else { + return + } + + if task == .memory { + memorySnapshot = StatusMemorySnapshot.current() + } + activeTask = task + reports.removeValue(forKey: task) + + Task { + let report = await service.perform(task) + reports[task] = report + if let memoryAfter = report.memoryAfter { + memorySnapshot = memoryAfter + } else if task == .memory { + memorySnapshot = StatusMemorySnapshot.current() + } + activeTask = nil + } + } + + private func refreshMemorySnapshot() async { + memorySnapshot = StatusMemorySnapshot.current() + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + memorySnapshot = StatusMemorySnapshot.current() + } + } +} + +private struct SystemMaintenanceCard: View { + let task: SystemMaintenanceTask + let isRunning: Bool + let isDisabled: Bool + let report: SystemMaintenanceReport? + let liveMemorySnapshot: StatusMemorySnapshot? + let action: () -> Void + + var body: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: task.systemImage) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(task.tint) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 5) { + Text(task.title) + .font(.title3.bold()) + Text(task.detail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let liveMemorySnapshot { + SystemMemorySnapshotPanel( + snapshot: report?.memoryAfter ?? liveMemorySnapshot, + report: report, + isRunning: isRunning + ) + } + + Text(task.commandSummary) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + + Label(L.t("system.authorization.note"), systemImage: "lock.shield") + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + + Button(action: action) { + Label(task.buttonTitle, systemImage: isRunning ? "hourglass" : task.buttonSystemImage) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isRunning || isDisabled) + + if isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(L.t("system.status.running")) + .foregroundStyle(.secondary) + } + .font(.caption) + } + + if let report { + SystemMaintenanceReportView(report: report) + } + } + } + } +} + +private struct SystemMemorySnapshotPanel: View { + let snapshot: StatusMemorySnapshot + let report: SystemMaintenanceReport? + let isRunning: Bool + + private var percentText: String { + percentText(snapshot.fraction) + } + + private var beforeAfterText: String? { + guard let before = report?.memoryBefore, let after = report?.memoryAfter else { + return nil + } + return L.f( + "system.memory.gauge.beforeAfter", + percentText(before.fraction), + percentText(after.fraction) + ) + } + + var body: some View { + HStack(alignment: .center, spacing: 14) { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.13), lineWidth: 8) + + Circle() + .trim(from: 0, to: min(max(snapshot.fraction, 0), 1)) + .stroke( + AngularGradient( + colors: [.purple, .blue, .purple], + center: .center + ), + style: StrokeStyle(lineWidth: 8, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .shadow(color: .purple.opacity(0.22), radius: 5) + .animation(.easeOut(duration: 0.45), value: snapshot.fraction) + + Text(percentText) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + } + .frame(width: 82, height: 82) + + VStack(alignment: .leading, spacing: 7) { + Label(L.t("system.memory.gauge.title"), systemImage: "memorychip") + .font(.subheadline.weight(.bold)) + + Text(L.f( + "system.memory.gauge.used", + CleanMacFormatters.bytes(snapshot.usedBytes), + CleanMacFormatters.bytes(snapshot.totalBytes) + )) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + if let beforeAfterText { + Text(beforeAfterText) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + + memoryChangeLabel + } + + Spacer(minLength: 0) + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.purple.opacity(0.18)) + } + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var memoryChangeLabel: some View { + if let before = report?.memoryBefore, let after = report?.memoryAfter { + let deltaBytes = before.usedBytes - after.usedBytes + if deltaBytes > 64 * 1024 * 1024 { + Label(L.f("system.memory.gauge.freed", CleanMacFormatters.bytes(deltaBytes)), systemImage: "arrow.down.circle.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(.green) + } else if deltaBytes < -(64 * 1024 * 1024) { + Label(L.f("system.memory.gauge.rose", CleanMacFormatters.bytes(abs(deltaBytes))), systemImage: "arrow.up.circle.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(.orange) + } else { + Label(L.t("system.memory.gauge.noChange"), systemImage: "equal.circle.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(.orange) + } + } else if isRunning { + Label(L.t("system.memory.gauge.measuring"), systemImage: "waveform.path.ecg") + .font(.caption.weight(.bold)) + .foregroundStyle(.purple) + } else { + Label(L.t("system.memory.gauge.live"), systemImage: "dot.radiowaves.left.and.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + } + } + + private func percentText(_ fraction: Double) -> String { + "\(Int((min(max(fraction, 0), 1) * 100).rounded()))%" + } +} + +private struct SystemMaintenanceReportView: View { + let report: SystemMaintenanceReport + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Label(report.status.title, systemImage: report.status.systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(report.status.tint) + + ForEach(report.commandResults) { result in + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Image(systemName: result.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(result.succeeded ? .green : .orange) + Text(result.commandLine) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(exitCodeText(result.exitCode)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + + if result.usedAdministratorPrivileges { + Label(L.t("system.result.administrator"), systemImage: "lock.open") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + + let message = resultMessage(result) + if !message.isEmpty { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + .padding(9) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + } + } + + private func exitCodeText(_ exitCode: Int32?) -> String { + guard let exitCode else { + return L.t("system.result.unavailable") + } + return L.f("system.result.exitCode", Int(exitCode)) + } + + private func resultMessage(_ result: SystemMaintenanceCommandResult) -> String { + if !result.errorOutput.isEmpty { + return result.errorOutput + } + if !result.output.isEmpty { + return result.output + } + return result.succeeded ? L.t("system.result.completed") : L.t("system.result.noOutput") + } +} + +private extension SystemMaintenanceTask { + var title: String { + switch self { + case .memory: L.t("system.memory.title") + case .dnsCache: L.t("system.dns.title") + } + } + + var detail: String { + switch self { + case .memory: L.t("system.memory.detail") + case .dnsCache: L.t("system.dns.detail") + } + } + + var buttonTitle: String { + switch self { + case .memory: L.t("system.memory.button") + case .dnsCache: L.t("system.dns.button") + } + } + + var systemImage: String { + switch self { + case .memory: "memorychip" + case .dnsCache: "network" + } + } + + var buttonSystemImage: String { + switch self { + case .memory: "bolt.fill" + case .dnsCache: "arrow.triangle.2.circlepath" + } + } + + var commandSummary: String { + switch self { + case .memory: "/usr/sbin/purge" + case .dnsCache: "/usr/bin/dscacheutil -flushcache\n/usr/bin/killall -HUP mDNSResponder" + } + } + + var tint: Color { + switch self { + case .memory: .purple + case .dnsCache: .teal + } + } +} + +private extension SystemMaintenanceReportStatus { + var title: String { + switch self { + case .success: L.t("system.status.success") + case .partial: L.t("system.status.partial") + case .failed: L.t("system.status.failed") + case .unavailable: L.t("system.status.unavailable") + } + } + + var systemImage: String { + switch self { + case .success: "checkmark.circle.fill" + case .partial: "exclamationmark.circle.fill" + case .failed: "xmark.octagon.fill" + case .unavailable: "questionmark.circle.fill" + } + } + + var tint: Color { + switch self { + case .success: .green + case .partial: .orange + case .failed: .red + case .unavailable: .secondary + } + } +} diff --git a/CleanMac/en.lproj/Localizable.strings b/CleanMac/en.lproj/Localizable.strings index b8afc31..6a1195d 100644 --- a/CleanMac/en.lproj/Localizable.strings +++ b/CleanMac/en.lproj/Localizable.strings @@ -5,6 +5,7 @@ "section.scan" = "Scan"; "section.results" = "Results"; "section.diskAnalysis" = "Disk Analysis"; +"section.system" = "System"; "section.duplicates" = "Duplicates"; "section.shredder" = "Shredder"; "section.applications" = "Applications"; @@ -154,6 +155,8 @@ "results.safeMode.title" = "Safe Mode is on"; "results.safeMode.message" = "Items marked Review stay visible, but are locked until you turn off Safe Mode in Settings."; "results.safeMode.locked" = "Locked by Safe Mode"; +"results.codexRuntimes.title" = "Old Codex installer runtimes found"; +"results.codexRuntimes.message" = "Only codex-runtime-install-* folders older than 7 days are shown. The current codex-primary-runtime is always protected, and nothing is selected automatically."; "results.cleanupDisabled.help" = "Cleanup moves allowlisted items to Trash after confirmation."; "results.summary.items" = "%d items"; "results.summary.unavailable" = "Not available"; @@ -204,6 +207,8 @@ "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.staleCodexRuntimeInstaller.title" = "Old Codex runtime installer"; +"results.reason.staleCodexRuntimeInstaller.detail" = "This is an incomplete or superseded codex-runtime-install-* directory older than 7 days. The active codex-primary-runtime is 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"; @@ -231,6 +236,7 @@ "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."; +"cleanup.confirm.codexRuntime.message" = "CleanMac will move %d selected item(s), %@ total, to Trash, including old Codex runtime installer folders. The current Codex runtime is protected. Confirm this reviewed cleanup."; "cleanup.noneSelected" = "Select at least one result before cleanup."; "cleanup.success" = "Moved %d item(s), %@ total, to Trash."; "cleanup.problems" = "Some items were not cleaned. Failed: %d, rejected by safety checks: %d."; @@ -336,6 +342,8 @@ "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.staleCodexRuntimes.title" = "Old Codex Runtime Installers"; +"area.staleCodexRuntimes.detail" = "Finds only codex-runtime-install-* folders older than 7 days. The current runtime is excluded and results require review."; "area.logs.title" = "Logs"; "area.logs.detail" = "Rotated and older diagnostic logs, skipping likely active files."; "area.temporary.title" = "Temporary Files"; @@ -363,9 +371,9 @@ "date.unknown" = "Unknown"; "applications.title" = "Applications"; -"applications.subtitle" = "Review third-party apps before moving them to Trash"; +"applications.subtitle" = "Review third-party apps before removing them"; "applications.safety.title" = "Safe removal boundaries"; -"applications.safety.message" = "Only apps directly inside Applications folders are shown. Personal data, Application Support, Containers, and system apps are excluded."; +"applications.safety.message" = "Only apps directly inside Applications folders are shown. Exact bundle-ID leftovers are reviewed separately; system apps and broad personal folders are excluded."; "applications.status.title" = "Removal complete"; "applications.problem.title" = "Removal needs review"; "applications.list.title" = "Installed apps"; @@ -380,21 +388,41 @@ "applications.location.user" = "Current user"; "applications.location.shared" = "All users"; "applications.leftovers.title" = "Related leftovers"; -"applications.leftovers.message" = "Optional and unchecked by default. Only exact bundle-ID cache, preferences, saved state, and log paths are offered."; +"applications.leftovers.message" = "Optional in Trash mode. Only exact bundle-ID paths are offered."; +"applications.leftovers.permanent.message" = "Permanent mode includes every exact bundle-ID leftover shown here."; "applications.leftovers.empty" = "No exact related leftovers found"; "applications.leftover.cache" = "Cache"; "applications.leftover.preferences" = "Preferences"; "applications.leftover.savedState" = "Saved application state"; "applications.leftover.logs" = "Logs"; +"applications.leftover.applicationSupport" = "Application Support"; +"applications.leftover.container" = "Container"; +"applications.leftover.groupContainer" = "Group Container"; +"applications.leftover.httpStorage" = "HTTP storage"; +"applications.leftover.webKit" = "WebKit data"; +"applications.leftover.cookies" = "Cookies"; +"applications.leftover.applicationScripts" = "Application scripts"; +"applications.leftover.launchAgent" = "Launch agent"; "applications.selected.summary" = "%d app(s) selected · %@"; "applications.selection.help" = "Include this application in removal"; +"applications.mode.title" = "Removal mode"; +"applications.mode.trash" = "Trash"; +"applications.mode.permanent" = "Permanent"; +"applications.mode.trash.detail" = "Moves the app and selected leftovers to Trash so they can still be restored from Finder."; +"applications.mode.permanent.detail" = "Deletes the app and every shown leftover directly. This bypasses Trash and cannot be restored by CleanMac."; "applications.removal.summary" = "%d app(s) · %d leftover(s) · %@ total"; "applications.removal.trash" = "Each app is moved first. Nothing is permanently deleted."; +"applications.removal.permanent" = "Each app is deleted first. If that fails, its leftovers are not touched."; "applications.remove.button" = "Delete Application"; "applications.remove.multiple" = "Delete Applications (%d)"; +"applications.remove.permanent.button" = "Delete Permanently"; +"applications.remove.permanent.multiple" = "Delete Permanently (%d)"; "applications.confirm.title" = "Move selected applications to Trash?"; "applications.confirm.message" = "CleanMac will move %d selected app(s) and %d leftover(s), %@ total, to Trash. This always requires confirmation."; +"applications.confirm.permanent.title" = "Delete selected applications permanently?"; +"applications.confirm.permanent.message" = "CleanMac will permanently delete %d selected app(s) and %d exact leftover(s), %@ total, without Trash or restore history. This cannot be undone."; "applications.removal.success" = "Moved %d app(s) and %d leftover(s), %@ total, to Trash."; +"applications.removal.permanent.success" = "Permanently deleted %d app(s) and %d leftover(s), %@ total."; "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."; @@ -522,6 +550,38 @@ "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."; +"system.title" = "System"; +"system.subtitle" = "Manual memory and DNS maintenance"; +"system.safety.title" = "Runs only after your click"; +"system.safety.message" = "These actions do not delete user files and are never started by scans or schedules. macOS may ask for an administrator password."; +"system.memory.title" = "Free inactive memory"; +"system.memory.detail" = "Asks macOS to reclaim inactive memory and disk buffers. Apps stay open, and macOS may refill memory as needed."; +"system.memory.button" = "Free Memory"; +"system.memory.gauge.title" = "Memory now"; +"system.memory.gauge.used" = "%@ of %@ used"; +"system.memory.gauge.beforeAfter" = "Before %@ → after %@"; +"system.memory.gauge.freed" = "Freed %@"; +"system.memory.gauge.rose" = "Memory rose by %@"; +"system.memory.gauge.noChange" = "No visible drop"; +"system.memory.gauge.live" = "Live memory reading"; +"system.memory.gauge.measuring" = "Measuring before and after cleanup…"; +"system.dns.title" = "Flush DNS cache"; +"system.dns.detail" = "Clears local DNS resolver caches and refreshes mDNSResponder. Network settings and saved Wi-Fi data are not changed."; +"system.dns.button" = "Flush DNS"; +"system.limits.title" = "macOS remains in control"; +"system.limits.message" = "CleanMac asks macOS for administrator authorization only after you press a button. The password prompt is owned by macOS, and CleanMac does not store credentials. Memory pressure can return immediately when apps need it."; +"system.authorization.note" = "Requires macOS administrator authorization"; +"system.status.running" = "Running system command…"; +"system.status.success" = "Completed"; +"system.status.partial" = "Partially completed"; +"system.status.failed" = "Failed"; +"system.status.unavailable" = "Unavailable"; +"system.result.exitCode" = "code %d"; +"system.result.unavailable" = "n/a"; +"system.result.completed" = "Completed without additional output."; +"system.result.noOutput" = "No output was returned."; +"system.result.administrator" = "Authorized by macOS administrator prompt"; + "shredder.header.code" = "SECURE_ERASE // OFFLINE"; "shredder.title" = "Smart Shredder"; "shredder.subtitle" = "Review regular files, then overwrite and unlink them directly."; @@ -565,6 +625,21 @@ "shredder.error.changed" = "The file changed or was replaced after review."; "shredder.error.busy" = "The file is busy or locked by another process."; "shredder.error.io" = "The overwrite or direct removal failed; the file was not reported as removed."; +"shredder.animation.machine" = "SECURE SHREDDER"; +"shredder.animation.position" = "File %d of %d"; +"shredder.animation.accessibility" = "%@, %d percent complete"; +"shredder.animation.preparing" = "Preparing preview"; +"shredder.animation.feeding" = "Feeding file"; +"shredder.animation.overwriting" = "Overwriting data"; +"shredder.animation.finalizing" = "Finalizing deletion"; +"shredder.animation.success" = "File removed"; +"shredder.animation.failure" = "Deletion stopped"; +"shredder.animation.detail.preparing" = "Creating a local Quick Look preview"; +"shredder.animation.detail.feeding" = "The file is entering the cutting mechanism"; +"shredder.animation.detail.overwriting" = "Random data is being written to the file"; +"shredder.animation.detail.finalizing" = "Syncing, truncating, and unlinking the file"; +"shredder.animation.detail.success" = "Direct removal completed successfully"; +"shredder.animation.detail.failure" = "The file was not reported as removed"; "onboarding.appTitle" = "Meet CleanMac"; "onboarding.skip" = "Skip introduction"; diff --git a/CleanMac/ru.lproj/Localizable.strings b/CleanMac/ru.lproj/Localizable.strings index aeadeda..ae0de7c 100644 --- a/CleanMac/ru.lproj/Localizable.strings +++ b/CleanMac/ru.lproj/Localizable.strings @@ -5,6 +5,7 @@ "section.scan" = "Сканирование"; "section.results" = "Результаты"; "section.diskAnalysis" = "Анализ диска"; +"section.system" = "Система"; "section.duplicates" = "Дубликаты"; "section.shredder" = "Шредер"; "section.applications" = "Приложения"; @@ -154,6 +155,8 @@ "results.safeMode.title" = "Безопасный режим включён"; "results.safeMode.message" = "Элементы «Нужно проверить» остаются видимыми, но выбрать их можно только после отключения безопасного режима в Настройках."; "results.safeMode.locked" = "Заблокировано безопасным режимом"; +"results.codexRuntimes.title" = "Найдены старые установочные runtimes Codex"; +"results.codexRuntimes.message" = "Показаны только папки codex-runtime-install-* старше 7 дней. Текущий codex-primary-runtime всегда защищён, ничего не выбирается автоматически."; "results.cleanupDisabled.help" = "Очистка перемещает разрешённые элементы в Корзину после подтверждения."; "results.summary.items" = "Элементов: %d"; "results.summary.unavailable" = "Недоступно"; @@ -204,6 +207,8 @@ "results.reason.developerIDECache.detail" = "Это точная папка кэша Cursor или VS Code; настройки, расширения, рабочие области и резервные копии исключены."; "results.reason.developerAITemporaryFile.title" = "Временные данные AI-инструмента"; "results.reason.developerAITemporaryFile.detail" = "Элемент находится в точной папке кэша или временных файлов Codex/Claude; проекты, сессии, история и память исключены."; +"results.reason.staleCodexRuntimeInstaller.title" = "Старый установочный runtime Codex"; +"results.reason.staleCodexRuntimeInstaller.detail" = "Это незавершённая или заменённая папка codex-runtime-install-* старше 7 дней. Активный codex-primary-runtime исключён."; "results.reason.staleLog.title" = "Старый лог"; "results.reason.staleLog.detail" = "Лог выглядит старше окна активного использования, поэтому предложен для безопасной проверки очистки."; "results.reason.rotatedLog.title" = "Ротированный лог"; @@ -231,6 +236,7 @@ "cleanup.confirm.title" = "Переместить выбранное в Корзину?"; "cleanup.confirm.message" = "CleanMac переместит выбранные элементы: %d, общий объём: %@, в Корзину. При необходимости их можно восстановить."; +"cleanup.confirm.codexRuntime.message" = "CleanMac переместит выбранные элементы: %d, общий объём: %@, в Корзину, включая старые установочные папки runtime Codex. Текущий runtime Codex защищён. Подтверди проверенную очистку."; "cleanup.noneSelected" = "Выбери хотя бы один результат перед очисткой."; "cleanup.success" = "Перемещено в Корзину: %d, общий объём: %@."; "cleanup.problems" = "Некоторые элементы не очищены. Ошибок: %d, отклонено проверкой безопасности: %d."; @@ -336,6 +342,8 @@ "area.developerIDEs.detail" = "Только кэши. Настройки, расширения, рабочие области и резервные копии не включаются."; "area.developerAI.title" = "Временные данные Codex и Claude"; "area.developerAI.detail" = "Только точные папки кэша и временных файлов. Проекты, сессии, история, shell snapshots и память не затрагиваются."; +"area.staleCodexRuntimes.title" = "Старые установочные runtimes Codex"; +"area.staleCodexRuntimes.detail" = "Находит только папки codex-runtime-install-* старше 7 дней. Текущий runtime исключён, результаты требуют проверки."; "area.logs.title" = "Логи"; "area.logs.detail" = "Старые и ротированные диагностические логи, без активных файлов."; "area.temporary.title" = "Временные файлы"; @@ -363,9 +371,9 @@ "date.unknown" = "Неизвестно"; "applications.title" = "Приложения"; -"applications.subtitle" = "Проверка сторонних программ перед перемещением в Корзину"; +"applications.subtitle" = "Проверка сторонних программ перед удалением"; "applications.safety.title" = "Безопасные границы удаления"; -"applications.safety.message" = "Показаны только приложения непосредственно из папок Applications. Личные данные, Application Support, Containers и системные приложения исключены."; +"applications.safety.message" = "Показаны только приложения непосредственно из папок Applications. Точные хвосты по bundle ID проверяются отдельно; системные приложения и широкие личные папки исключены."; "applications.status.title" = "Удаление завершено"; "applications.problem.title" = "Нужно проверить удаление"; "applications.list.title" = "Установленные приложения"; @@ -380,21 +388,41 @@ "applications.location.user" = "Текущего пользователя"; "applications.location.shared" = "Всех пользователей"; "applications.leftovers.title" = "Связанные остатки"; -"applications.leftovers.message" = "Необязательно и изначально не выбрано. Предлагаются только точные пути кэша, настроек, сохранённого состояния и логов по bundle ID."; +"applications.leftovers.message" = "В режиме Корзины необязательно. Предлагаются только точные пути по bundle ID."; +"applications.leftovers.permanent.message" = "В полном режиме будут включены все точные хвосты, показанные здесь."; "applications.leftovers.empty" = "Точные связанные остатки не найдены"; "applications.leftover.cache" = "Кэш"; "applications.leftover.preferences" = "Настройки"; "applications.leftover.savedState" = "Сохранённое состояние"; "applications.leftover.logs" = "Логи"; +"applications.leftover.applicationSupport" = "Application Support"; +"applications.leftover.container" = "Контейнер"; +"applications.leftover.groupContainer" = "Group Container"; +"applications.leftover.httpStorage" = "HTTP-хранилище"; +"applications.leftover.webKit" = "Данные WebKit"; +"applications.leftover.cookies" = "Cookies"; +"applications.leftover.applicationScripts" = "Скрипты приложения"; +"applications.leftover.launchAgent" = "Launch Agent"; "applications.selected.summary" = "Выбрано приложений: %d · %@"; "applications.selection.help" = "Добавить приложение к удалению"; +"applications.mode.title" = "Режим удаления"; +"applications.mode.trash" = "В Корзину"; +"applications.mode.permanent" = "Полностью"; +"applications.mode.trash.detail" = "Перемещает приложение и выбранные хвосты в Корзину, откуда их ещё можно восстановить через Finder."; +"applications.mode.permanent.detail" = "Удаляет приложение и все показанные хвосты напрямую. Корзина и восстановление CleanMac не используются."; "applications.removal.summary" = "Приложений: %d · остатков: %d · всего %@"; "applications.removal.trash" = "Сначала перемещается каждое приложение. Ничего не удаляется навсегда."; +"applications.removal.permanent" = "Сначала удаляется каждое приложение. Если это не удалось, его хвосты не трогаются."; "applications.remove.button" = "Удалить приложение"; "applications.remove.multiple" = "Удалить приложения (%d)"; +"applications.remove.permanent.button" = "Удалить полностью"; +"applications.remove.permanent.multiple" = "Удалить полностью (%d)"; "applications.confirm.title" = "Переместить выбранные приложения в Корзину?"; "applications.confirm.message" = "CleanMac переместит выбранные приложения: %d, остатки: %d, общий объём: %@, в Корзину. Это действие всегда требует подтверждения."; +"applications.confirm.permanent.title" = "Удалить выбранные приложения полностью?"; +"applications.confirm.permanent.message" = "CleanMac навсегда удалит выбранные приложения: %d, точные хвосты: %d, общий объём: %@, без Корзины и истории восстановления. Это нельзя отменить."; "applications.removal.success" = "В Корзину перемещено приложений: %d, остатков: %d, общий объём: %@."; +"applications.removal.permanent.success" = "Полностью удалено приложений: %d, остатков: %d, общий объём: %@."; "applications.removal.problems" = "Некоторые элементы не перемещены. Ошибок: %d, отклонено проверкой безопасности: %d."; "applications.detail.empty.title" = "Выбери приложение"; "applications.detail.empty.message" = "Выбери программу, чтобы проверить её путь и необязательные точные остатки."; @@ -522,6 +550,38 @@ "duplicates.empty.results.title" = "Точных дубликатов не найдено"; "duplicates.empty.results.message" = "Копий для выбора нет. Крупные кандидаты могут быть доступны через медленный режим."; +"system.title" = "Система"; +"system.subtitle" = "Ручное обслуживание памяти и DNS"; +"system.safety.title" = "Запускается только по вашему клику"; +"system.safety.message" = "Эти действия не удаляют пользовательские файлы и никогда не запускаются сканированием или расписанием. macOS может запросить пароль администратора."; +"system.memory.title" = "Освободить неактивную память"; +"system.memory.detail" = "Просит macOS вернуть неактивную память и дисковые буферы. Приложения остаются открытыми, а macOS может снова занять память при необходимости."; +"system.memory.button" = "Освободить память"; +"system.memory.gauge.title" = "Память сейчас"; +"system.memory.gauge.used" = "Занято %@ из %@"; +"system.memory.gauge.beforeAfter" = "До %@ → после %@"; +"system.memory.gauge.freed" = "Освобождено %@"; +"system.memory.gauge.rose" = "Память выросла на %@"; +"system.memory.gauge.noChange" = "Заметного снижения нет"; +"system.memory.gauge.live" = "Живой показатель памяти"; +"system.memory.gauge.measuring" = "Измеряю до и после очистки…"; +"system.dns.title" = "Очистить кэш DNS"; +"system.dns.detail" = "Сбрасывает локальные кэши DNS-резолвера и обновляет mDNSResponder. Сетевые настройки и сохранённые Wi-Fi данные не меняются."; +"system.dns.button" = "Очистить DNS"; +"system.limits.title" = "macOS остаётся главным"; +"system.limits.message" = "CleanMac запрашивает авторизацию администратора macOS только после нажатия кнопки. Окно пароля принадлежит macOS, а CleanMac не хранит учётные данные. Давление на память может вернуться сразу, когда приложениям снова понадобится RAM."; +"system.authorization.note" = "Требуется авторизация администратора macOS"; +"system.status.running" = "Выполняю системную команду…"; +"system.status.success" = "Готово"; +"system.status.partial" = "Частично выполнено"; +"system.status.failed" = "Ошибка"; +"system.status.unavailable" = "Недоступно"; +"system.result.exitCode" = "код %d"; +"system.result.unavailable" = "н/д"; +"system.result.completed" = "Выполнено без дополнительного вывода."; +"system.result.noOutput" = "Команда не вернула вывод."; +"system.result.administrator" = "Авторизовано через запрос администратора macOS"; + "shredder.header.code" = "SECURE_ERASE // OFFLINE"; "shredder.title" = "Умный шредер"; "shredder.subtitle" = "Проверка обычных файлов, перезапись и прямое удаление без Корзины."; @@ -565,6 +625,21 @@ "shredder.error.changed" = "Файл изменился или был заменён после проверки."; "shredder.error.busy" = "Файл занят или заблокирован другим процессом."; "shredder.error.io" = "Перезапись или прямое удаление завершились ошибкой; файл не отмечен как удалённый."; +"shredder.animation.machine" = "БЕЗОПАСНЫЙ ШРЕДЕР"; +"shredder.animation.position" = "Файл %d из %d"; +"shredder.animation.accessibility" = "%@: выполнено %d процентов"; +"shredder.animation.preparing" = "Подготовка превью"; +"shredder.animation.feeding" = "Файл втягивается"; +"shredder.animation.overwriting" = "Перезапись данных"; +"shredder.animation.finalizing" = "Завершение удаления"; +"shredder.animation.success" = "Файл удалён"; +"shredder.animation.failure" = "Удаление остановлено"; +"shredder.animation.detail.preparing" = "Создание локального Quick Look-превью"; +"shredder.animation.detail.feeding" = "Файл поступает в режущий механизм"; +"shredder.animation.detail.overwriting" = "В файл записываются случайные данные"; +"shredder.animation.detail.finalizing" = "Синхронизация, обнуление и удаление записи"; +"shredder.animation.detail.success" = "Прямое удаление успешно завершено"; +"shredder.animation.detail.failure" = "Файл не отмечен как удалённый"; "onboarding.appTitle" = "Знакомство с CleanMac"; "onboarding.skip" = "Пропустить знакомство"; diff --git a/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift b/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift index a1f74df..2ba5b0d 100644 --- a/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift +++ b/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift @@ -10,6 +10,19 @@ public enum ApplicationLeftoverKind: String, CaseIterable, Sendable { case preferences case savedApplicationState case logs + case applicationSupport + case container + case groupContainer + case httpStorage + case webKit + case cookies + case applicationScripts + case launchAgent +} + +public enum ApplicationRemovalMode: String, CaseIterable, Sendable { + case moveToTrash + case deletePermanently } public struct ApplicationLeftover: Identifiable, Equatable, Sendable { @@ -295,6 +308,22 @@ public struct InstalledApplicationScanner { homeDirectory.appending(path: "Library/Saved Application State/\(bundleIdentifier).savedState", directoryHint: .isDirectory) case .logs: homeDirectory.appending(path: "Library/Logs/\(bundleIdentifier)", directoryHint: .isDirectory) + case .applicationSupport: + homeDirectory.appending(path: "Library/Application Support/\(bundleIdentifier)", directoryHint: .isDirectory) + case .container: + homeDirectory.appending(path: "Library/Containers/\(bundleIdentifier)", directoryHint: .isDirectory) + case .groupContainer: + homeDirectory.appending(path: "Library/Group Containers/\(bundleIdentifier)", directoryHint: .isDirectory) + case .httpStorage: + homeDirectory.appending(path: "Library/HTTPStorages/\(bundleIdentifier)", directoryHint: .isDirectory) + case .webKit: + homeDirectory.appending(path: "Library/WebKit/\(bundleIdentifier)", directoryHint: .isDirectory) + case .cookies: + homeDirectory.appending(path: "Library/Cookies/\(bundleIdentifier).binarycookies") + case .applicationScripts: + homeDirectory.appending(path: "Library/Application Scripts/\(bundleIdentifier)", directoryHint: .isDirectory) + case .launchAgent: + homeDirectory.appending(path: "Library/LaunchAgents/\(bundleIdentifier).plist") } } @@ -569,17 +598,20 @@ public struct ApplicationRemovalFailedItem: Identifiable, Equatable, Sendable { public struct ApplicationRemovalReport: Equatable, Sendable { public let completedAt: Date + public let mode: ApplicationRemovalMode public let movedItems: [ApplicationRemovalMovedItem] public let failedItems: [ApplicationRemovalFailedItem] public let rejectedItems: [ApplicationRemovalRejectedItem] public init( completedAt: Date, + mode: ApplicationRemovalMode = .moveToTrash, movedItems: [ApplicationRemovalMovedItem], failedItems: [ApplicationRemovalFailedItem], rejectedItems: [ApplicationRemovalRejectedItem] ) { self.completedAt = completedAt + self.mode = mode self.movedItems = movedItems self.failedItems = failedItems self.rejectedItems = rejectedItems @@ -600,10 +632,16 @@ public struct ApplicationRemovalReport: Equatable, Sendable { public struct ApplicationRemovalExecutor { public typealias TrashHandler = (URL) throws -> URL? + public typealias PermanentDeleteHandler = (URL) throws -> Void private let trashHandler: TrashHandler + private let permanentDeleteHandler: PermanentDeleteHandler - public init(fileManager: FileManager = .default, trashHandler: TrashHandler? = nil) { + public init( + fileManager: FileManager = .default, + trashHandler: TrashHandler? = nil, + permanentDeleteHandler: PermanentDeleteHandler? = nil + ) { if let trashHandler { self.trashHandler = trashHandler } else { @@ -613,12 +651,24 @@ public struct ApplicationRemovalExecutor { return resultingURL as URL? } } + + if let permanentDeleteHandler { + self.permanentDeleteHandler = permanentDeleteHandler + } else { + self.permanentDeleteHandler = { url in + try fileManager.removeItem(at: url) + } + } } - public func execute(plan: ApplicationRemovalPlan) -> ApplicationRemovalReport { + public func execute( + plan: ApplicationRemovalPlan, + mode: ApplicationRemovalMode = .moveToTrash + ) -> ApplicationRemovalReport { guard let applicationItem = plan.applicationItem else { return ApplicationRemovalReport( completedAt: Date(), + mode: mode, movedItems: [], failedItems: [], rejectedItems: plan.rejectedItems @@ -629,7 +679,7 @@ public struct ApplicationRemovalExecutor { var failedItems: [ApplicationRemovalFailedItem] = [] do { - let trashedURL = try trashHandler(URL(fileURLWithPath: applicationItem.path)) + let trashedURL = try remove(item: applicationItem, mode: mode) movedItems.append(ApplicationRemovalMovedItem( item: applicationItem, trashedPath: trashedURL?.path @@ -641,6 +691,7 @@ public struct ApplicationRemovalExecutor { )) return ApplicationRemovalReport( completedAt: Date(), + mode: mode, movedItems: movedItems, failedItems: failedItems, rejectedItems: plan.rejectedItems @@ -649,7 +700,7 @@ public struct ApplicationRemovalExecutor { for leftoverItem in plan.leftoverItems { do { - let trashedURL = try trashHandler(URL(fileURLWithPath: leftoverItem.path)) + let trashedURL = try remove(item: leftoverItem, mode: mode) movedItems.append(ApplicationRemovalMovedItem( item: leftoverItem, trashedPath: trashedURL?.path @@ -664,9 +715,24 @@ public struct ApplicationRemovalExecutor { return ApplicationRemovalReport( completedAt: Date(), + mode: mode, movedItems: movedItems, failedItems: failedItems, rejectedItems: plan.rejectedItems ) } + + private func remove( + item: ApplicationRemovalPlanItem, + mode: ApplicationRemovalMode + ) throws -> URL? { + let url = URL(fileURLWithPath: item.path) + switch mode { + case .moveToTrash: + return try trashHandler(url) + case .deletePermanently: + try permanentDeleteHandler(url) + return nil + } + } } diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift b/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift index b859cea..79e71fe 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupModels.swift @@ -8,6 +8,7 @@ public enum CleanupCategory: String, CaseIterable, Codable, Identifiable, Sendab case developerPackageCaches = "developer-package-cache" case developerIDECaches = "developer-ide-cache" case developerAITemporaryFiles = "developer-ai-temp" + case staleCodexRuntimeInstallers = "codex-runtime-installers" case logs case temporaryFiles = "temp" case trash @@ -35,6 +36,7 @@ public enum CleanupScanReason: String, Equatable, Sendable { case developerPackageCache case developerIDECache case developerAITemporaryFile + case staleCodexRuntimeInstaller case staleLog case rotatedLog case staleTemporary @@ -50,30 +52,38 @@ public enum CleanupScanReason: String, Equatable, Sendable { } public struct CleanupScanOptions: Equatable, Sendable { + public static let defaultStaleCodexRuntimeAge: TimeInterval = 7 * 24 * 60 * 60 + public var maxItemsPerCategory: Int public var maxDescendantsPerItem: Int + public var maxStaleCodexRuntimeDescendants: Int public var largeDownloadThresholdBytes: Int64 public var staleDownloadAge: TimeInterval public var staleLogAge: TimeInterval public var staleTemporaryAge: TimeInterval public var staleDeveloperDataAge: TimeInterval + public var staleCodexRuntimeAge: TimeInterval public init( maxItemsPerCategory: Int = 80, maxDescendantsPerItem: Int = 2_000, + maxStaleCodexRuntimeDescendants: Int = 100_000, largeDownloadThresholdBytes: Int64 = 100 * 1024 * 1024, staleDownloadAge: TimeInterval = 30 * 24 * 60 * 60, staleLogAge: TimeInterval = 7 * 24 * 60 * 60, staleTemporaryAge: TimeInterval = 24 * 60 * 60, - staleDeveloperDataAge: TimeInterval = 180 * 24 * 60 * 60 + staleDeveloperDataAge: TimeInterval = 180 * 24 * 60 * 60, + staleCodexRuntimeAge: TimeInterval = CleanupScanOptions.defaultStaleCodexRuntimeAge ) { self.maxItemsPerCategory = max(1, maxItemsPerCategory) self.maxDescendantsPerItem = max(1, maxDescendantsPerItem) + self.maxStaleCodexRuntimeDescendants = max(1, maxStaleCodexRuntimeDescendants) self.largeDownloadThresholdBytes = max(1, largeDownloadThresholdBytes) self.staleDownloadAge = max(0, staleDownloadAge) self.staleLogAge = max(0, staleLogAge) self.staleTemporaryAge = max(0, staleTemporaryAge) self.staleDeveloperDataAge = max(0, staleDeveloperDataAge) + self.staleCodexRuntimeAge = max(CleanupScanOptions.defaultStaleCodexRuntimeAge, staleCodexRuntimeAge) } } diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift b/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift index 550d573..8d2cf42 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupPathPolicy.swift @@ -1,6 +1,23 @@ import Darwin import Foundation +enum CleanupPathRules { + static let codexRuntimeInstallerPrefix = "codex-runtime-install-" + static let codexPrimaryRuntimeName = "codex-primary-runtime" + + static func isCodexRuntimeInstallerName(_ name: String) -> Bool { + guard name != codexPrimaryRuntimeName, + name.hasPrefix(codexRuntimeInstallerPrefix) else { + return false + } + + let suffix = name.dropFirst(codexRuntimeInstallerPrefix.count) + return !suffix.isEmpty && suffix.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + } +} + struct CleanupRootResolver { let homeDirectory: URL let temporaryDirectory: URL @@ -54,6 +71,8 @@ struct CleanupRootResolver { homeDirectory.appending(path: ".claude/paste-cache", directoryHint: .isDirectory), homeDirectory.appending(path: "Library/Caches/com.anthropic.claudefordesktop", directoryHint: .isDirectory) ] + case .staleCodexRuntimeInstallers: + [homeDirectory.appending(path: ".cache/codex-runtimes", directoryHint: .isDirectory)] case .logs: [homeDirectory.appending(path: "Library/Logs", directoryHint: .isDirectory)] case .temporaryFiles: diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift b/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift index bebc3c0..e4b4485 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupPlanner.swift @@ -16,12 +16,12 @@ public struct CleanupPlanner { ) } - public func plan(for items: [CleanupScanItem]) -> CleanupPlan { + public func plan(for items: [CleanupScanItem], referenceDate: Date = Date()) -> CleanupPlan { var acceptedItems: [CleanupPlanItem] = [] var rejectedItems: [CleanupRejectedItem] = [] for item in items { - switch validate(item) { + switch validate(item, referenceDate: referenceDate) { case .accepted(let planItem): acceptedItems.append(planItem) case .rejected(let rejectedItem): @@ -41,7 +41,7 @@ public struct CleanupPlanner { case rejected(CleanupRejectedItem) } - private func validate(_ item: CleanupScanItem) -> ValidationResult { + private func validate(_ item: CleanupScanItem, referenceDate: Date) -> ValidationResult { let url = URL(fileURLWithPath: item.path) guard fileManager.fileExists(atPath: url.path) else { @@ -80,6 +80,7 @@ public struct CleanupPlanner { } let directChildCategories: Set = [ + .staleCodexRuntimeInstallers, .xcodeDeviceSupport, .xcodePreviews, .xcodeSimulatorData @@ -104,6 +105,23 @@ public struct CleanupPlanner { )) } + if item.category == .staleCodexRuntimeInstallers { + let runtimeValues = try? url.resourceValues(forKeys: [ + .isDirectoryKey, + .contentModificationDateKey + ]) + guard runtimeValues?.isDirectory == true, + CleanupPathRules.isCodexRuntimeInstallerName(url.lastPathComponent), + let modifiedAt = runtimeValues?.contentModificationDate, + referenceDate.timeIntervalSince(modifiedAt) >= CleanupScanOptions.defaultStaleCodexRuntimeAge else { + return .rejected(rejection( + for: item, + reason: .outsideAllowedRoot, + message: "Only stale Codex runtime installer directories can be cleaned." + )) + } + } + return .accepted(CleanupPlanItem(scanItem: item, originalPath: canonicalPath)) } diff --git a/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift b/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift index 28df97a..a9a5c79 100644 --- a/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift +++ b/CleanMacCore/Sources/CleanMacCore/CleanupScanner.swift @@ -228,6 +228,7 @@ public struct CleanupScanner { .developerPackageCaches, .developerIDECaches, .developerAITemporaryFiles, + .staleCodexRuntimeInstallers, .downloadedInstallers, .xcodePreviews: 3 @@ -283,6 +284,16 @@ public struct CleanupScanner { case .xcodeSimulatorData: let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) return isOlderThan(values?.contentModificationDate, options.staleDeveloperDataAge) + case .staleCodexRuntimeInstallers: + let values = try? url.resourceValues(forKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey, + .contentModificationDateKey + ]) + return values?.isDirectory == true + && values?.isSymbolicLink != true + && CleanupPathRules.isCodexRuntimeInstallerName(url.lastPathComponent) + && isOlderThan(values?.contentModificationDate, options.staleCodexRuntimeAge) default: return true } @@ -305,6 +316,7 @@ public struct CleanupScanner { let measuredSize = measureSize( at: url, isDirectory: isDirectory, + category: category, options: options ) let reasons = scanReasons( @@ -337,6 +349,7 @@ public struct CleanupScanner { private func measureSize( at url: URL, isDirectory: Bool, + category: CleanupCategory, options: CleanupScanOptions ) -> (bytes: Int64, isEstimate: Bool) { guard isDirectory else { @@ -348,18 +361,25 @@ public struct CleanupScanner { var scannedDescendants = 0 var isEstimate = false + let enumerationOptions: FileManager.DirectoryEnumerationOptions = category == .staleCodexRuntimeInstallers + ? [] + : [.skipsPackageDescendants] guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: Array(resourceKeys), - options: [.skipsPackageDescendants], + options: enumerationOptions, errorHandler: { _, _ in true } ) else { return (bytes, true) } + let descendantLimit = category == .staleCodexRuntimeInstallers + ? options.maxStaleCodexRuntimeDescendants + : options.maxDescendantsPerItem + for case let childURL as URL in enumerator { scannedDescendants += 1 - if scannedDescendants > options.maxDescendantsPerItem { + if scannedDescendants > descendantLimit { isEstimate = true break } @@ -413,6 +433,7 @@ public struct CleanupScanner { .xcodePreviews: .safe case .trash, + .staleCodexRuntimeInstallers, .downloads, .downloadedInstallers, .xcodeDerivedData, @@ -445,6 +466,11 @@ public struct CleanupScanner { return [.developerIDECache] case .developerAITemporaryFiles: return [.developerAITemporaryFile] + case .staleCodexRuntimeInstallers: + return CleanupPathRules.isCodexRuntimeInstallerName(url.lastPathComponent) + && isOlderThan(resourceValues?.contentModificationDate, options.staleCodexRuntimeAge) + ? [.staleCodexRuntimeInstaller] + : [] case .downloads: var reasons: [CleanupScanReason] = [] if isDownloadedInstallerOrArchive(url) { diff --git a/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift b/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift index 6cc3a51..0ca7cc1 100644 --- a/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift +++ b/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift @@ -46,6 +46,31 @@ public enum SecureDeletionError: Error, Equatable, Sendable { case removeFailed(Int32) } +public struct SecureDeletionProgress: Equatable, Sendable { + public enum Stage: Equatable, Sendable { + case overwriting + case finalizing + case complete + } + + public let stage: Stage + public let completedBytes: Int64 + public let totalBytes: Int64 + + public init(stage: Stage, completedBytes: Int64, totalBytes: Int64) { + self.stage = stage + self.completedBytes = max(0, completedBytes) + self.totalBytes = max(0, totalBytes) + } + + public var fractionCompleted: Double { + guard totalBytes > 0 else { + return stage == .overwriting ? 0 : 1 + } + return min(1, Double(completedBytes) / Double(totalBytes)) + } +} + public struct SecureFileShredder: Sendable { private static let bufferSize = 1024 * 1024 @@ -126,7 +151,10 @@ public struct SecureFileShredder: Sendable { } @discardableResult - public func shred(_ candidate: SecureDeletionCandidate) throws -> Int64 { + public func shred( + _ candidate: SecureDeletionCandidate, + progress: (@Sendable (SecureDeletionProgress) -> Void)? = nil + ) throws -> Int64 { let candidateURL = URL(fileURLWithPath: candidate.path).standardizedFileURL let canonicalURL = candidateURL.resolvingSymlinksInPath().standardizedFileURL guard !Self.isProtected(path: candidate.path), !Self.isProtected(path: canonicalURL.path) else { @@ -155,13 +183,29 @@ public struct SecureFileShredder: Sendable { } try validate(candidate: candidate, metadata: openedMetadata, includeMutableMetadata: true) + progress?(SecureDeletionProgress( + stage: .overwriting, + completedBytes: 0, + totalBytes: candidate.sizeBytes + )) + if candidate.sizeBytes > 0 { - try overwrite(descriptor: descriptor, byteCount: candidate.sizeBytes) + try overwrite( + descriptor: descriptor, + byteCount: candidate.sizeBytes, + progress: progress + ) guard Darwin.fsync(descriptor) == 0 else { throw SecureDeletionError.syncFailed(errno) } } + progress?(SecureDeletionProgress( + stage: .finalizing, + completedBytes: candidate.sizeBytes, + totalBytes: candidate.sizeBytes + )) + try beforeUnlink(candidate.path) guard Darwin.ftruncate(descriptor, 0) == 0 else { @@ -181,16 +225,28 @@ public struct SecureFileShredder: Sendable { throw SecureDeletionError.removeFailed(errno) } + progress?(SecureDeletionProgress( + stage: .complete, + completedBytes: candidate.sizeBytes, + totalBytes: candidate.sizeBytes + )) + return candidate.sizeBytes } - private func overwrite(descriptor: Int32, byteCount: Int64) throws { + private func overwrite( + descriptor: Int32, + byteCount: Int64, + progress: (@Sendable (SecureDeletionProgress) -> Void)? + ) throws { guard Darwin.lseek(descriptor, 0, SEEK_SET) >= 0 else { throw SecureDeletionError.writeFailed(errno) } var remaining = byteCount var buffer = [UInt8](repeating: 0, count: Self.bufferSize) + let reportStep = max(Int64(Self.bufferSize), max(1, byteCount / 100)) + var nextReportAt = min(byteCount, reportStep) while remaining > 0 { let chunkSize = min(Int64(buffer.count), remaining) @@ -223,6 +279,15 @@ public struct SecureFileShredder: Sendable { } remaining -= chunkSize + let completedBytes = byteCount - remaining + if completedBytes >= nextReportAt || remaining == 0 { + progress?(SecureDeletionProgress( + stage: .overwriting, + completedBytes: completedBytes, + totalBytes: byteCount + )) + nextReportAt = min(byteCount, completedBytes + reportStep) + } } } diff --git a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift index fb99a71..519e5db 100644 --- a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift +++ b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift @@ -120,7 +120,8 @@ final class CleanMacCoreTests: XCTestCase { let paths = [ CleanupCategory.developerPackageCaches, .developerIDECaches, - .developerAITemporaryFiles + .developerAITemporaryFiles, + .staleCodexRuntimeInstallers ].flatMap { resolver.rootURLs(for: $0).map(\.path) } let requiredSuffixes = [ @@ -134,6 +135,7 @@ final class CleanMacCoreTests: XCTestCase { "/.codex/.tmp", "/.codex/tmp", "/.codex/cache", + "/.cache/codex-runtimes", "/.claude/cache", "/.claude/paste-cache" ] @@ -216,6 +218,76 @@ final class CleanMacCoreTests: XCTestCase { }) } + func testStaleCodexRuntimeScannerProtectsCurrentAndRecentRuntimes() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let referenceDate = Date() + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let runtimeRoot = home.appending(path: ".cache/codex-runtimes", directoryHint: .isDirectory) + let staleInstaller = runtimeRoot.appending(path: "codex-runtime-install-AbC123", directoryHint: .isDirectory) + let recentInstaller = runtimeRoot.appending(path: "codex-runtime-install-XyZ789", directoryHint: .isDirectory) + let currentRuntime = runtimeRoot.appending(path: CleanupPathRules.codexPrimaryRuntimeName, directoryHint: .isDirectory) + let invalidInstaller = runtimeRoot.appending(path: "codex-runtime-install-not_allowed", directoryHint: .isDirectory) + + for directory in [staleInstaller, recentInstaller, currentRuntime, invalidInstaller] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try writeBytes(count: 32, to: directory.appending(path: "runtime.bin")) + } + try setModificationDate(referenceDate.addingTimeInterval(-8 * 24 * 60 * 60), for: staleInstaller) + try setModificationDate(referenceDate.addingTimeInterval(-24 * 60 * 60), for: recentInstaller) + try setModificationDate(referenceDate.addingTimeInterval(-30 * 24 * 60 * 60), for: currentRuntime) + try setModificationDate(referenceDate.addingTimeInterval(-30 * 24 * 60 * 60), for: invalidInstaller) + + let scanner = CleanupScanner(homeDirectory: home, temporaryDirectory: root.appending(path: "Temp")) + let report = scanner.scan( + categories: [.staleCodexRuntimeInstallers], + options: CleanupScanOptions( + maxItemsPerCategory: 10, + maxDescendantsPerItem: 1, + maxStaleCodexRuntimeDescendants: 100 + ) + ) + + XCTAssertEqual(report.items.map(\.displayName), ["codex-runtime-install-AbC123"]) + XCTAssertEqual(report.items.first?.risk, .review) + XCTAssertEqual(report.items.first?.reasons, [.staleCodexRuntimeInstaller]) + XCTAssertEqual(report.items.first?.isSizeEstimate, false) + XCTAssertFalse(report.items.contains { $0.displayName == CleanupPathRules.codexPrimaryRuntimeName }) + } + + func testStaleCodexRuntimePlannerRevalidatesNameAgeAndDirectChild() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let referenceDate = Date() + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let runtimeRoot = home.appending(path: ".cache/codex-runtimes", directoryHint: .isDirectory) + let staleInstaller = runtimeRoot.appending(path: "codex-runtime-install-Old123", directoryHint: .isDirectory) + let recentInstaller = runtimeRoot.appending(path: "codex-runtime-install-New123", directoryHint: .isDirectory) + let currentRuntime = runtimeRoot.appending(path: CleanupPathRules.codexPrimaryRuntimeName, directoryHint: .isDirectory) + let invalidInstaller = runtimeRoot.appending(path: "codex-runtime-install-invalid_name", directoryHint: .isDirectory) + let nestedInstaller = staleInstaller.appending(path: "codex-runtime-install-Nested123", directoryHint: .isDirectory) + + for directory in [staleInstaller, recentInstaller, currentRuntime, invalidInstaller, nestedInstaller] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + try setModificationDate(referenceDate.addingTimeInterval(-8 * 24 * 60 * 60), for: staleInstaller) + try setModificationDate(referenceDate.addingTimeInterval(-24 * 60 * 60), for: recentInstaller) + try setModificationDate(referenceDate.addingTimeInterval(-30 * 24 * 60 * 60), for: currentRuntime) + try setModificationDate(referenceDate.addingTimeInterval(-30 * 24 * 60 * 60), for: invalidInstaller) + try setModificationDate(referenceDate.addingTimeInterval(-30 * 24 * 60 * 60), for: nestedInstaller) + + let items = [staleInstaller, recentInstaller, currentRuntime, invalidInstaller, nestedInstaller].map { + makeScanItem(category: .staleCodexRuntimeInstallers, path: $0.path, isDirectory: true) + } + let plan = CleanupPlanner(homeDirectory: home, temporaryDirectory: root.appending(path: "Temp")) + .plan(for: items, referenceDate: referenceDate) + + XCTAssertEqual(plan.items.map(\.originalPath), [canonicalPath(staleInstaller.path)]) + XCTAssertEqual(plan.rejectedItems.count, 4) + } + func testXcodeDeveloperStorageUsesReviewAndAgeRules() throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -533,13 +605,22 @@ final class CleanMacCoreTests: XCTestCase { let savedState = home.appending(path: "Library/Saved Application State/com.example.editor.savedState", directoryHint: .isDirectory) let logs = home.appending(path: "Library/Logs/com.example.editor", directoryHint: .isDirectory) let applicationSupport = home.appending(path: "Library/Application Support/com.example.editor", directoryHint: .isDirectory) - - for folder in [cache, savedState, logs, applicationSupport] { + let container = home.appending(path: "Library/Containers/com.example.editor", directoryHint: .isDirectory) + let groupContainer = home.appending(path: "Library/Group Containers/com.example.editor", directoryHint: .isDirectory) + let httpStorage = home.appending(path: "Library/HTTPStorages/com.example.editor", directoryHint: .isDirectory) + let webKit = home.appending(path: "Library/WebKit/com.example.editor", directoryHint: .isDirectory) + let cookies = home.appending(path: "Library/Cookies/com.example.editor.binarycookies") + let applicationScripts = home.appending(path: "Library/Application Scripts/com.example.editor", directoryHint: .isDirectory) + let launchAgent = home.appending(path: "Library/LaunchAgents/com.example.editor.plist") + + for folder in [cache, savedState, logs, applicationSupport, container, groupContainer, httpStorage, webKit, applicationScripts] { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try writeBytes(count: 8, to: folder.appending(path: "data.bin")) } - try FileManager.default.createDirectory(at: preferences.deletingLastPathComponent(), withIntermediateDirectories: true) - try writeBytes(count: 8, to: preferences) + for file in [preferences, cookies, launchAgent] { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeBytes(count: 8, to: file) + } let alias = sharedApplications.appending(path: "Example Alias.app") try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: thirdPartyApp) @@ -557,7 +638,7 @@ final class CleanMacCoreTests: XCTestCase { Set(report.applications.first?.leftovers.map(\.kind) ?? []), Set(ApplicationLeftoverKind.allCases) ) - XCTAssertFalse(report.applications.first?.leftovers.contains { $0.path == applicationSupport.path } ?? true) + XCTAssertTrue(report.applications.first?.leftovers.contains { $0.path == applicationSupport.path } ?? false) XCTAssertTrue(FileManager.default.fileExists(atPath: thirdPartyApp.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: applicationSupport.path)) } @@ -612,11 +693,68 @@ final class CleanMacCoreTests: XCTestCase { XCTAssertEqual(report.failedItems.count, 0) XCTAssertEqual(report.rejectedItems.count, 0) XCTAssertEqual(movedPaths.first, appURL.path) - XCTAssertEqual(Set(movedPaths.dropFirst()), Set([cache.path, preferences.path])) + XCTAssertEqual(Set(movedPaths.dropFirst()), Set([cache.path, preferences.path, applicationSupport.path])) XCTAssertFalse(FileManager.default.fileExists(atPath: appURL.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: preferences.path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: applicationSupport.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: applicationSupport.path)) + } + + func testApplicationRemovalCanDeleteAppAndExactLeftoversPermanently() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let applications = root.appending(path: "Applications", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: applications, withIntermediateDirectories: true) + + let appURL = try makeFakeApplication( + named: "Example", + bundleIdentifier: "com.example.editor", + in: applications + ) + let cache = home.appending(path: "Library/Caches/com.example.editor", directoryHint: .isDirectory) + let applicationSupport = home.appending(path: "Library/Application Support/com.example.editor", directoryHint: .isDirectory) + for folder in [cache, applicationSupport] { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try writeBytes(count: 8, to: folder.appending(path: "data.bin")) + } + + let application = try XCTUnwrap(InstalledApplicationScanner( + applicationDirectories: [applications], + homeDirectory: home, + maxDescendantsPerItem: 100 + ).scan().applications.first) + let plan = ApplicationRemovalPlanner( + applicationDirectories: [applications], + homeDirectory: home + ).plan( + for: application, + selectedLeftoverIDs: Set(application.leftovers.map(\.id)) + ) + + var deletedPaths: [String] = [] + let report = ApplicationRemovalExecutor( + trashHandler: { _ in + XCTFail("Permanent removal must not use Trash") + throw NSError(domain: "CleanMacCoreTests", code: 2) + }, + permanentDeleteHandler: { url in + deletedPaths.append(url.path) + try FileManager.default.removeItem(at: url) + } + ).execute(plan: plan, mode: .deletePermanently) + + XCTAssertEqual(report.mode, .deletePermanently) + XCTAssertTrue(report.applicationMoved) + XCTAssertEqual(report.failedItems.count, 0) + XCTAssertEqual(report.rejectedItems.count, 0) + XCTAssertEqual(deletedPaths.first, appURL.path) + XCTAssertEqual(Set(deletedPaths.dropFirst()), Set([cache.path, applicationSupport.path])) + XCTAssertTrue(report.movedItems.allSatisfy { $0.trashedPath == nil }) + XCTAssertFalse(FileManager.default.fileExists(atPath: appURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: applicationSupport.path)) } func testApplicationRemovalDoesNotTouchLeftoversWhenAppMoveFails() throws { @@ -1101,6 +1239,7 @@ final class CleanMacCoreTests: XCTestCase { .downloads, .downloadedInstallers, .trash, + .staleCodexRuntimeInstallers, .xcodeDerivedData, .xcodeDeviceSupport, .xcodeSimulatorData, diff --git a/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift index 25f0867..05f9bbb 100644 --- a/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift +++ b/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift @@ -14,14 +14,49 @@ final class SecureFileShredderTests: XCTestCase { let candidate = try SecureFileShredder().inspect(url: file) let probe = ShredOverwriteProbe(original: original) + let progressProbe = ShredProgressProbe() let shredder = SecureFileShredder(beforeUnlink: probe.inspect) - let removedBytes = try shredder.shred(candidate) + let removedBytes = try shredder.shred(candidate, progress: progressProbe.record) XCTAssertEqual(removedBytes, Int64(original.count)) XCTAssertTrue(probe.didInspect) XCTAssertTrue(probe.wasFullyOverwritten) XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + + let progressEvents = progressProbe.events + XCTAssertEqual(progressEvents.first?.stage, .overwriting) + XCTAssertEqual(progressEvents.first?.completedBytes, 0) + XCTAssertTrue(progressEvents.contains { $0.stage == .finalizing }) + XCTAssertEqual(progressEvents.last?.stage, .complete) + XCTAssertEqual(progressEvents.last?.fractionCompleted, 1) + + let overwriteBytes = progressEvents + .filter { $0.stage == .overwriting } + .map(\.completedBytes) + XCTAssertEqual(overwriteBytes, overwriteBytes.sorted()) + } + + func testFailedFinalRemovalNeverReportsComplete() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let file = root.appending(path: "blocked.bin") + try Data(repeating: 0x52, count: 2 * 1024 * 1024).write(to: file) + + let candidate = try SecureFileShredder().inspect(url: file) + let progressProbe = ShredProgressProbe() + let shredder = SecureFileShredder { _ in + throw SecureDeletionError.removeFailed(EPERM) + } + + XCTAssertThrowsError(try shredder.shred(candidate, progress: progressProbe.record)) { error in + XCTAssertEqual(error as? SecureDeletionError, .removeFailed(EPERM)) + } + + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path)) + XCTAssertEqual(progressProbe.events.last?.stage, .finalizing) + XCTAssertFalse(progressProbe.events.contains { $0.stage == .complete }) } func testInspectionRejectsDirectoriesSymlinksHardLinksAndPackages() throws { @@ -93,6 +128,21 @@ final class SecureFileShredderTests: XCTestCase { } } +private final class ShredProgressProbe: @unchecked Sendable { + private let lock = NSLock() + private var recordedEvents: [SecureDeletionProgress] = [] + + var events: [SecureDeletionProgress] { + lock.withLock { recordedEvents } + } + + func record(_ progress: SecureDeletionProgress) { + lock.withLock { + recordedEvents.append(progress) + } + } +} + private final class ShredOverwriteProbe: @unchecked Sendable { private let lock = NSLock() private let original: Data diff --git a/README.md b/README.md index f6ab334..6d6d780 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Safe, local-first cleanup for macOS with review-first scanning,
- Safe Mode, Trash-based cleanup, and an isolated Smart Shredder. + Safe Mode, reviewed app removal, system maintenance, and an isolated Smart Shredder.

@@ -17,7 +17,7 @@ MIT License

-CleanMac is a native SwiftUI utility that scans selected macOS locations, explains every cleanup candidate, and moves confirmed cleanup items to Trash. An isolated Smart Shredder is available only for files the user selects explicitly. Everything runs locally: the code contains no scan-result uploads, analytics, or cloud accounts. +CleanMac is a native SwiftUI utility that scans selected macOS locations, explains every cleanup candidate, and moves confirmed cleanup items to Trash. Application removal can stay Trash-based or, after an explicit mode switch, directly delete the app plus exact bundle-ID leftovers. Manual system maintenance can ask macOS to free inactive memory or flush DNS cache. An isolated Smart Shredder is available only for files the user selects explicitly. Everything runs locally: the code contains no scan-result uploads, analytics, or cloud accounts. > [!IMPORTANT] > The current public build is ad-hoc signed and is not notarized by Apple. macOS Gatekeeper may block the downloaded app. You can build CleanMac from source for development; do not disable system security to run an unsigned file. @@ -42,10 +42,11 @@ CleanMac is a native SwiftUI utility that scans selected macOS locations, explai - **Safe scanning.** User and browser caches, logs, temporary files, Xcode Derived Data, Node/SwiftPM caches, Downloads, installers, and Trash. - **Explainable review.** Categories, sizes, risk levels, recommendation reasons, exact paths, and locations that could not be read. - **Safe Mode.** Enabled by default and prevents selection of items that require manual review. -- **Trash-based cleanup.** Normal cleanup, duplicate removal, and application removal validate accepted paths again and move them to Trash. +- **Trash-based cleanup.** Normal cleanup and duplicate removal validate accepted paths again and move them to Trash. - **Smart Shredder.** A separate hacker-style workspace performs best-effort overwrite and direct deletion only after file review, acknowledgement, and an exact typed phrase. It rejects folders, links, packages, protected roots, and changed files. - **Session restore.** Items moved during the current session can be restored when their original path is available. -- **Application removal.** Finds third-party apps in `/Applications` and `~/Applications`, supports multi-selection, and offers optional exact bundle-ID leftovers. +- **Application removal.** Finds third-party apps in `/Applications` and `~/Applications`, supports multi-selection, offers exact bundle-ID leftovers, and provides separate Trash and permanent modes. +- **System maintenance.** Manual buttons can ask macOS for administrator authorization, then run `purge` for inactive memory or flush local DNS caches. The memory action shows live usage and a before/after result. - **Menu bar and scheduled scans.** Disk status, the latest scan summary, safe scan scheduling, and local notifications while CleanMac is running. - **Permission visibility.** Live Full Disk Access and Finder Automation status without automatic permission prompts. - **English and Russian UI.** Switch language and light/dark appearance directly inside the app. @@ -56,20 +57,22 @@ CleanMac is a native SwiftUI utility that scans selected macOS locations, explai 2. Every candidate belongs to a known category and an allowlisted root path. 3. Cleanup requires explicit selection and a separate confirmation. 4. Paths are validated again immediately before execution. -5. Normal cleanup files are moved to Trash instead of being permanently deleted. -6. During application removal, the `.app` bundle is moved first. Its leftovers remain untouched if that step fails. -7. Smart Shredder is isolated from scans, recommendations, scheduling, Trash history, and restore. Its direct deletion is intentionally irreversible at the filesystem level, but physical erasure cannot be guaranteed on SSD/APFS. -8. CleanMac does not escalate privileges or install a system helper. +5. Normal cleanup files and duplicate copies are moved to Trash instead of being permanently deleted. +6. Application removal defaults to Trash mode. Permanent mode must be selected explicitly and includes every shown exact bundle-ID leftover. +7. During application removal, the `.app` bundle is removed first. Its leftovers remain untouched if that step fails. +8. Smart Shredder is isolated from scans, recommendations, scheduling, Trash history, and restore. Its direct deletion is intentionally irreversible at the filesystem level, but physical erasure cannot be guaranteed on SSD/APFS. +9. System maintenance actions run only from explicit buttons, use fixed absolute macOS command paths, and do not run through scheduled scans. macOS owns the administrator password prompt; CleanMac does not store credentials. +10. CleanMac does not install a privileged helper and never stores administrator credentials. ## Installation -Download the latest archive from [GitHub Releases](https://github.com/Dezoff-max/CleanMac/releases/latest). Each ZIP is published with a `.sha256` file: +Download a package from [GitHub Releases](https://github.com/Dezoff-max/CleanMac/releases/latest). Packages produced from the current source include `CleanMac.dmg`: open it and drag `CleanMac.app` onto the included `Applications` shortcut. Older releases may provide only the fallback ZIP. Each generated archive has its own `.sha256` file: ```bash -shasum -a 256 CleanMac-*.zip +shasum -a 256 CleanMac.dmg ``` -Compare the resulting hash with the first value in the attached `.sha256` file. Then extract the archive and move `CleanMac.app` to `/Applications`. +Compare the resulting hash with the first value in `CleanMac.dmg.sha256`. Current prebuilt release limitations: @@ -103,7 +106,7 @@ Run the core test suite: swift test --package-path CleanMacCore ``` -Create a local Release ZIP and SHA-256 file: +Create a local Release app, drag-to-Applications DMG, fallback ZIP, and SHA-256 files: ```bash ./script/package_release.sh diff --git a/contract.md b/contract.md index 2feed60..206d576 100644 --- a/contract.md +++ b/contract.md @@ -2,60 +2,68 @@ ## Task -- ID: TASK-047 -- Title: CleanMac v0.4.0 release +- ID: TASK-056 +- Title: Real Smart Shredder destruction animation - Mode: continue ## Planner Notes -- Why this task now: the user explicitly requested that the release be updated after the thermal optimization and Smart Shredder feature were pushed. -- Expected value: publish the verified feature set as the next minor release with reproducible archive and checksum assets. -- Main risk: tagging an unverified commit or presenting an ad-hoc build as notarized. -- Release choice: bump `0.3.0 (4)` to `0.4.0 (5)` because the release adds a user-facing feature; keep the existing ad-hoc/unsigned distribution wording unless signing secrets produce a genuinely signed/notarized artifact in CI. +- Why this task now: Smart Shredder performed the irreversible operation correctly but showed no trustworthy visual relationship between the selected file, overwrite progress, final unlink, and outcome. +- Expected value: the user sees a real Quick Look preview enter the mechanism, split into strips, fall into the bin, and reach success only when the secure core has actually removed the file. +- Main risk: a decorative timer could reach 100% or show success before the descriptor-based operation finishes, masking an I/O or unlink failure. +- Safety choice: emit progress from the real overwrite loop, reserve completion for the post-unlink event, keep failed files queued, generate the preview before mutation, support Reduce Motion, and exercise destructive UI verification only on disposable files created under `/private/tmp`. ## Builder Scope - Allowed files: - - `CleanMac.xcodeproj/project.pbxproj`; - - `README.md` and `docs/screenshots/`; - - release and Loop documentation files. + - `CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift`; + - `CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift`; + - `CleanMac/Views/ShredderView.swift`; + - `CleanMac/Views/ShredderAnimationView.swift`; + - `CleanMac/en.lproj/Localizable.strings`; + - `CleanMac/ru.lproj/Localizable.strings`; + - Loop documentation. - Allowed commands: - - source/version inspection; - - full SwiftPM tests; - - Debug build/launch verification; - - local Release packaging and fresh-extraction validation; - - non-destructive app navigation and screenshot capture without accepting cleanup or shredder actions; - - git branch/commit/push and GitHub PR merge; - - create and push tag `v0.4.0`; - - inspect/edit the corresponding GitHub Release and verify its downloaded assets. + - focused and full SwiftPM tests; + - localization lint and key-parity checks; + - Debug and Release builds, app launch, and live visual verification; + - release packaging, checksums, and git checks; + - approved commit and GitHub PR update. - Out of scope: - - new feature code, changing deployment targets/dependencies, inventing signing credentials, disabling security, deleting user files, or claiming notarization without evidence. + - adding drag and drop or sound effects; + - adding extra overwrite passes or claiming guaranteed SSD/APFS physical erasure; + - changing the existing review and typed-confirmation safety boundary; + - cleanup, application removal, RAM, or DNS execution. - Dependencies allowed: none -- Destructive actions allowed: generated build/dist artifacts only +- Destructive actions allowed: secure removal only of disposable SwiftPM fixtures and agent-created `/private/tmp/CleanMac-Shredder-*-Test.png` copies; replacing generated ignored `dist/` artifacts ## Evaluator Checklist - Done criteria: - - bundle reports version `0.4.0` and build `5`; - - all core tests and Debug launch verification pass on the release commit; - - local Release ZIP extracts with a valid strict ad-hoc signature and matching SHA-256; - - release version commit is merged into `main` before tagging; - - tag `v0.4.0` points to that verified `main` commit; - - GitHub Release is published with ZIP and `.sha256` assets and accurate notes/limitations; - - downloaded assets pass checksum and fresh-extraction signature/version inspection. + - the animation uses a real Quick Look thumbnail with an `NSWorkspace` fallback; + - the preview feeds into the mechanism and becomes 20 independently animated strips; + - overwrite progress comes from actual completed bytes, not a standalone timer; + - finalizing remains below 100%, and green success appears only after `unlink` completes; + - a failure never emits `complete`, remains visibly failed, and leaves the file queued; + - the focused operation view fits the standard window with the stage and progress footer visible; + - Reduce Motion removes large fall/rotation motion while preserving readable state changes; + - RU and EN contain the same localized animation states. - Required verification: + - `swift test --package-path CleanMacCore --filter SecureFileShredderTests`; - `swift test --package-path CleanMacCore`; + - localization plist lint and RU/EN key parity; - `./script/build_and_run.sh --verify`; + - live focused animation screenshots against disposable files; - `./script/package_release.sh`; - - bundle version, architecture, signature, and checksum inspection; - - GitHub CI/Release workflow and clean asset download verification; + - `zsh -lc 'cd dist && shasum -a 256 -c *.sha256'`; - `git diff --check`. - Manual checks: - - Keep release publication separate from commit/push and tag creation. - - Do not claim Developer ID signing or notarization unless the published asset proves it. + - never select or remove a real user file; + - confirm the disposable file exists before the run and is absent only after the green success phase; + - do not accept cleanup, application-removal, RAM, or DNS actions during visual verification. ## Result - Status: complete -- Verification result: PR #14 and PR #15 core/macOS CI passed; 47/47 core tests; Debug build/sign/launch passed; local Release packaging and clean extraction passed; published workflow run `29220077936` passed; downloaded ZIP checksum, version `0.4.0` build `5`, `arm64` architecture, and strict ad-hoc signature all passed. -- Notes: `v0.4.0` points to merge commit `125e528` and is published as the latest GitHub Release with ZIP and SHA-256 assets. Developer ID certificate and notary key steps were skipped because those secrets are not configured; the notes accurately label the asset ad-hoc signed and not notarized. +- Verification result: passed. Five focused Shredder tests and all 51 core tests pass; Debug and Release builds succeed; RU/EN parity is 657 keys; live disposable-file runs showed feed, strip fall, real 98% finalizing, and post-unlink 100% success with the entire scene visible; DMG/ZIP packaging and checksums pass. +- Notes: destructive verification touched only temporary copies created for this task. No real user file, cleanup target, installed app, RAM state, DNS cache, permission, or system setting was changed. The known CoreSimulator version warning remains unrelated and non-blocking. diff --git a/docs/signing-notarization.md b/docs/signing-notarization.md index f3f861d..7f7e5a1 100644 --- a/docs/signing-notarization.md +++ b/docs/signing-notarization.md @@ -10,21 +10,22 @@ Check available identities: security find-identity -p codesigning -v ``` -Build a local unsigned zip. The script still applies an ad-hoc signature so the -bundle can be verified locally, but Gatekeeper distribution still requires a -Developer ID certificate and notarization. +Build a local unsigned app, drag-to-Applications DMG, and fallback ZIP. The +script still applies an ad-hoc signature so the bundle can be verified locally, +but Gatekeeper distribution still requires a Developer ID certificate and +notarization. ```sh ./script/package_release.sh ``` -Build a Developer ID signed zip: +Build Developer ID signed app, DMG, and ZIP artifacts: ```sh CLEANMAC_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh ``` -Build, notarize, staple, and re-zip: +Build, notarize, staple, and recreate the DMG and ZIP: ```sh CLEANMAC_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ @@ -54,7 +55,7 @@ For GitHub Releases, add these private repository secrets when signing is ready: - `CLEANMAC_NOTARY_KEY_ID`: App Store Connect API key id. - `CLEANMAC_NOTARY_ISSUER`: App Store Connect issuer UUID. -If these secrets are absent, the Release workflow keeps producing an unsigned zip and checksum. +If these secrets are absent, the Release workflow keeps producing an ad-hoc signed DMG and ZIP with checksums. ## Validation diff --git a/progress.md b/progress.md index 22a80ed..9e03788 100644 --- a/progress.md +++ b/progress.md @@ -440,3 +440,91 @@ Append-only history. Do not erase previous entries. - Next step: Configure Developer ID and notarization secrets before a future trusted-distribution release. - Bottleneck: the release remains ad-hoc signed and not notarized because no Developer ID certificate/notary credentials are configured; Gatekeeper may reject it. The known stale CoreSimulator warning remains unrelated and non-blocking for macOS builds. - Handoff: Tag `v0.4.0` points to `125e528`. Release assets and verification copies are preserved under the task diagnostics folder. No user file was deleted and no destructive Shredder action was accepted. + +## 2026-07-13 - TASK-048 - Application removal modes + +- What changed: Added a two-mode Applications uninstaller. Trash mode remains the default and moves selected apps plus chosen leftovers to Trash. Permanent mode is an explicit segmented choice that auto-includes every shown exact bundle-ID leftover and directly deletes without Trash or CleanMac restore history. Exact leftover discovery now covers cache, preferences, saved state, logs, Application Support, Containers, Group Containers, HTTPStorages, WebKit, cookies, Application Scripts, and LaunchAgents. The Debug run script now retries signing verification if local Finder/File Provider metadata is reattached. +- Files touched: `CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift`, `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift`, `CleanMac/Views/ApplicationsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `script/build_and_run.sh`, `README.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `trace.md`, `verification.md`, `progress.md`. +- Checks run: `swift test --package-path CleanMacCore` (48/48); `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings`; RU/EN localization key parity; Debug `xcodebuild`; `./script/build_and_run.sh --verify` after metadata-race hardening; `git diff --check`. +- Result: Passed. Disposable app-removal fixtures prove the default Trash path and the permanent delete path, and prove the app is removed before any leftovers. The live Debug app builds, signs, launches, and is running. +- Next step: Review the Applications screen visually and decide whether permanent mode needs a typed phrase like Smart Shredder before it can execute. +- Bottleneck: direct deletion is intentionally irreversible and unprivileged; root-owned apps may still fail without administrator privileges, and CleanMac does not escalate. +- Handoff: No real installed app or user leftover was removed. Permanent deletion was exercised only inside temporary SwiftPM fixtures. + +## 2026-07-13 - TASK-049 - Shredder icon polish + +- What changed: Replaced the Shredder section icon from the terminal symbol to the system `scissors` symbol and reused that same section icon in the Shredder header mark so the feature reads as a shredding tool instead of a console. +- Files touched: `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/ShredderView.swift`, `contract.md`, `roadmap.md`, `progress.md`. +- Checks run: `swift test --package-path CleanMacCore` (48/48); `./script/build_and_run.sh --verify`; `git diff --check`. +- Result: Passed. The Debug app builds, signs after the existing metadata retry, and launches. +- Next step: Visually review the Shredder sidebar row in the running app and decide whether a custom sidebar-only asset is needed later. +- Bottleneck: none. The known CoreSimulator warning and FinderInfo retry remain non-blocking for this macOS Debug build. +- Handoff: UI-only change. No scan, cleanup, app removal, Shredder confirmation, package, release, or remote repository state was changed. + +## 2026-07-13 - TASK-050 - System maintenance actions + +- What changed: Added a new System sidebar workspace with two explicit maintenance actions: Free Memory runs only `/usr/sbin/purge`, and Flush DNS runs `/usr/bin/dscacheutil -flushcache` followed by `/usr/bin/killall -HUP mDNSResponder`. Commands are launched through `Process` with fixed absolute paths, no shell, no sudo, no scheduling, and visible running/success/partial/failure/unavailable states. +- Files touched: `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `README.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `verification.md`, `progress.md`. +- Checks run: command existence checks for `/usr/sbin/purge`, `/usr/bin/dscacheutil`, and `/usr/bin/killall`; `swift test --package-path CleanMacCore` (48/48); localization plist lint and RU/EN key parity; repeated `./script/build_and_run.sh --verify`; `git diff --check`. +- Result: Passed. The Debug app builds, signs, launches, and includes the new System section. +- Next step: Visually review the System screen and decide whether RAM/DNS actions should also appear in the menu-bar popover. +- Bottleneck: DNS refresh can partially fail if macOS denies signalling `mDNSResponder`; CleanMac reports that state and does not elevate privileges. +- Handoff: The RAM and DNS maintenance buttons were not clicked during verification, so no memory purge or DNS cache flush was executed. No scan, cleanup, app removal, Shredder confirmation, package, release, or remote repository state was changed. + +## 2026-07-13 - TASK-051 - Administrator-backed system maintenance + +- What changed: Changed System maintenance actions from normal user `Process` execution to fixed macOS administrator-authorized scripts. Free Memory now asks macOS for authorization and runs only `/usr/sbin/purge`; Flush DNS asks macOS for authorization and runs `/usr/bin/dscacheutil -flushcache` plus `/usr/bin/killall -HUP mDNSResponder`. The UI warns about the admin prompt and marks results as authorized by macOS. +- Files touched: `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `README.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `verification.md`, `progress.md`. +- Checks run: command existence checks for `/usr/sbin/purge`, `/usr/bin/dscacheutil`, `/usr/bin/killall`, and `/usr/bin/osascript`; `swift test --package-path CleanMacCore` (48/48); localization plist lint and RU/EN key parity; `./script/build_and_run.sh --verify`; `git diff --check`. +- Result: Passed. The Debug app builds, signs, launches, and the System actions are wired to macOS administrator authorization instead of failing as an unprivileged process. +- Next step: Manually click Free Memory in the app and confirm the macOS password prompt appears, then verify it reports success. +- Bottleneck: Actual RAM/DNS execution was not performed during automated verification to avoid changing system state without an explicit user click. If the user cancels the macOS administrator prompt, CleanMac reports a failure. +- Handoff: No RAM purge or DNS cache flush was executed by Codex. No scan, cleanup, app removal, Shredder confirmation, package, release, or remote repository state was changed. + +## 2026-07-13 - TASK-052 - Visible memory cleanup result + +- What changed: Added a shared read-only `StatusMemorySnapshot` so the menu bar and System screen use the same VM memory calculation. The Free Memory card now shows a live circular memory gauge before action, records memory before and after `/usr/sbin/purge`, and labels the result as freed, no visible drop, or memory rose again. +- Files touched: `CleanMac/Support/StatusSystemMetrics.swift`, `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `README.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `verification.md`, `progress.md`. +- Checks run: Debug `xcodebuild`; `swift test --package-path CleanMacCore` (48/48); localization plist lint and RU/EN key parity; `./script/build_and_run.sh --verify`; `pgrep -fl CleanMac`; safe System window list check; `git diff --check`. +- Result: Passed. The Debug app builds, signs, launches, and the System window opens with the new memory result UI compiled in. +- Next step: Click Free Memory manually and confirm the UI shows a before/after percentage plus the freed/no-change/rose-again label after the macOS administrator prompt. +- Bottleneck: Automated verification did not click Free Memory, so it did not produce a real before/after purge result. macOS can refill RAM quickly, so a successful command may still show no visible drop or a small increase. +- Handoff: No RAM purge or DNS cache flush was executed by Codex. No scan, cleanup, app removal, Shredder confirmation, package, release, or remote repository state was changed. + +## 2026-07-13 - TASK-053 - Modern scan progress and drag-to-Applications DMG + +- What changed: Replaced the gray scan-only system spinners with a shared orbit-style SwiftUI indicator across standard cleanup scanning, Disk Analysis, Duplicate Finder, and Applications scanning. Restored rotating arcs, soft pulse, shimmer, and signal motion through small state-driven animations with Reduce Motion support and no `TimelineView`. Release packaging now creates `dist/CleanMac.dmg` beside `dist/CleanMac.app`; the image contains `CleanMac.app` and an `Applications -> /Applications` shortcut, while CI and tag Release workflows publish the DMG alongside the fallback ZIP and checksums. +- Files touched: `CleanMac/Views/DiskAnalysisProgressIndicator.swift`, `CleanMac/Views/ScanActivityView.swift`, `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DuplicateFinderView.swift`, `CleanMac/Views/ApplicationsView.swift`, `script/package_release.sh`, `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `README.md`, `docs/signing-notarization.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: Debug `xcodebuild`; `bash -n script/package_release.sh`; full `swift test --package-path CleanMacCore` (48/48); localization plist lint and RU/EN key parity; `./script/build_and_run.sh --verify`; two `./script/package_release.sh` passes while hardening DMG staging; `hdiutil verify`; read-only DMG mount with shortcut and strict app signature inspection; `zsh -lc 'cd dist && shasum -a 256 -c *.sha256'`; source check confirming no scan-progress `TimelineView`; live standard scan and cancellable Home Disk Analysis review; six CPU samples; `git diff --check`. +- Result: Passed. The live standard scan showed the modern 70% orbital/progress surface, Disk Analysis showed the compact modern indicator, and the test analysis was cancelled with the app confirming that files were unchanged. The DMG and ZIP checksums pass, and the app inside the mounted DMG satisfies strict ad-hoc signature verification. +- Next step: Push only after resolving the repository visibility choice, then let CI validate the DMG artifact on GitHub. +- Bottleneck: `Dezoff-max/CleanMac` currently reports `PUBLIC`, which conflicts with the earlier private-repository requirement. This Mac still has no Developer ID identity, so the local DMG is ad-hoc signed and not notarized. + +## 2026-07-13 - TASK-054 - Safe stale Codex runtime review and native Applications icon + +- What changed: Added a narrowly scoped standard-scan category for exact direct `~/.cache/codex-runtimes/codex-runtime-install-[A-Za-z0-9]+` directories older than seven days. The scanner excludes the active `codex-primary-runtime`, recent installers, malformed names, nested paths, and symlinks; measures package contents with a category-specific descendant cap; and marks every result for review. Results start unselected, remain locked by Safe Mode, explain the protected active runtime, require a dedicated final confirmation, and move only planner-revalidated paths to Trash. Existing nonempty scan selections receive the category once through a schema migration. The Applications sidebar row now renders the installed system App Store Retina icon through `NSWorkspace` instead of an unavailable or imitation symbol. +- Files touched: `CleanMacCore/Sources/CleanMacCore/CleanupModels.swift`, `CleanupPathPolicy.swift`, `CleanupScanner.swift`, `CleanupPlanner.swift`, `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Support/CleanMacPreferences.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/Views/SidebarView.swift`, both localization files, and Loop docs. +- Checks: all 50 SwiftPM tests; localization plist lint and RU/EN key parity; real read-only runtime probe; `./script/build_and_run.sh --verify`; selected/unselected live icon review; `./script/package_release.sh`; DMG/ZIP checksum verification; `git diff --check`. +- Result: Passed. The real probe found two old exact installer runtimes totaling 1,772,158,976 bytes and excluded `codex-primary-runtime`; no cleanup ran. The native App Store icon is visible in both sidebar states. Debug and Release packaging passed. +- Next step: Review and merge PR #17, then create a feature release only when requested. +- Bottleneck: public distribution remains ad-hoc signed and not notarized until Developer ID credentials are configured. The known CoreSimulator version warning remains unrelated and non-blocking. + +## 2026-07-13 - TASK-055 - Monochrome Retina Applications sidebar icon + +- What changed: Replaced the full-color App Store application icon with Finder's own monochrome `SidebarApplicationsFolder.icns` mark. Instead of passing the multi-representation `.icns` directly to SwiftUI, CleanMac now extracts only its 36-pixel Retina bitmap, wraps it as one 18-point template image, and tints it with the existing sidebar state color. A monochrome SF Symbol remains the fallback, and no Apple binary asset is stored in the repository. +- Files touched: `CleanMac/Views/SidebarView.swift`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `trace.md`. +- Checks: `./script/build_and_run.sh --verify`; live unselected and selected screenshots; full SwiftPM tests; release package/checksums; `git diff --check`. +- Result: Passed. Applications renders as one crisp gray App Store-style mark when unselected and the same mark in white on the selected blue row. The earlier color mismatch and stacked striped `.icns` rendering are gone. +- Next step: Review and merge the updated draft PR #17, then publish a release only when requested. +- Bottleneck: public distribution remains ad-hoc signed and not notarized until Developer ID credentials are configured. The CoreSimulator warning remains unrelated and non-blocking. +- Handoff: `dist/` contains `CleanMac.app`, `CleanMac.dmg`, `CleanMac.dmg.sha256`, the fallback ZIP, and its checksum. No cleanup, removal, Shredder, RAM purge, DNS flush, permission, or system setting was changed. + +## 2026-07-13 - TASK-056 - Real Smart Shredder destruction animation + +- What changed: Added a focused Reduce Motion-aware destruction scene driven by the real secure-deletion lifecycle. CleanMac loads a real Quick Look preview with a workspace-icon fallback, feeds it through the mechanism, reveals 20 clipped strips from the same preview, and lets them fall into the bin while actual completed overwrite bytes drive progress. Finalizing remains below 100%; green success appears only after the core successfully unlinks the file. A failed final removal never emits completion, keeps the file queued, and shows a visible failure state instead of masking the error. +- Files touched: `CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift`, `CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift`, `CleanMac/Views/ShredderView.swift`, `CleanMac/Views/ShredderAnimationView.swift`, both localization files, and the Loop documentation. +- Checks run: focused `SecureFileShredderTests` (5/5); full `swift test --package-path CleanMacCore` (51/51); RU/EN localization plist lint and 657-key parity; `./script/build_and_run.sh --verify`; disposable live runs using approximately 722 KB, 68.2 MB, and 34.6 MB fixtures under `/private/tmp`; visual review of feeding, falling strips, 98% finalizing, and 100% success; `./script/package_release.sh`; DMG and ZIP checksum verification; `git diff --check`. +- Result: Passed. The entire animation fits the standard window, actual progress remains truthful, and each disposable fixture was confirmed absent only after the success state. +- Next step: Review draft PR #17 and merge or release only after explicit approval. +- Bottleneck: public distribution remains ad-hoc signed and not notarized until Developer ID credentials are configured. The known stale CoreSimulator warning remains unrelated and non-blocking. +- Handoff: No real user file was selected or removed. Only agent-created disposable copies under `/private/tmp` were securely deleted during live verification. diff --git a/project-analysis.md b/project-analysis.md index 6b10b34..b063a0e 100644 --- a/project-analysis.md +++ b/project-analysis.md @@ -9,7 +9,7 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - `CleanMac/`: macOS SwiftUI application and asset catalog. - `CleanMacCore/`: SwiftPM library for reusable cleanup/scanning logic. - `script/build_and_run.sh`: local Debug build and launch entrypoint. -- `script/package_release.sh`: local Release `.app`, `.zip`, and `.sha256` packaging. +- `script/package_release.sh`: local Release `.app`, drag-to-Applications `.dmg`, fallback `.zip`, and `.sha256` packaging. - `.github/workflows/ci.yml`: GitHub CI for core tests, Debug app build, and unsigned Release artifact. - `.github/workflows/release.yml`: idempotent tag-driven GitHub Release workflow for unsigned/ad-hoc zip and sha256 assets, with optional Developer ID signing/notarization when private secrets are configured; it creates a missing release or replaces matching assets on an existing release without overwriting its notes. - `docs/` and `Design/`: icon and project assets. @@ -40,26 +40,28 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - Local `dist/` packaging exists and is ignored by git. - 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. +- Sidebar navigation uses subtle modern hover, click, and keyboard focus feedback while preserving the selected accent row. Applications uses Finder's monochrome system Applications mark: one isolated 36-pixel Retina representation renders as an 18-point template, follows selected/unselected colors, and falls back to a monochrome SF Symbol when the system resource is unavailable. 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. +- Stale Codex installer runtimes have a separate review-only rule for exact direct `~/.cache/codex-runtimes/codex-runtime-install-[A-Za-z0-9]+` directories older than seven days. The active `codex-primary-runtime`, recent or malformed installers, nested paths, and symlinks are excluded; Results starts them unselected, Safe Mode locks them, and the planner repeats every boundary check before an explicitly confirmed move to Trash. - 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. - Long read-only analysis no longer drives the entire SwiftUI layout at 30 FPS. Disk Analysis and normal scan progress use event-driven/system progress visuals, pending progress streams keep only the newest event, and Disk Analysis/Duplicate Finder workers run at utility priority. A repeated Home analysis benchmark dropped from 111–124% CPU to a 28.6% startup sample followed by 1.5% and idle-level samples while the scan remained active. - 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. -- Smart Shredder is a separate neo-glow sidebar workspace for explicitly selected ordinary files. It rejects directories, symlinks, hard links, package contents, protected system roots, and files changed after review; requires an acknowledgement plus an exact typed phrase; then overwrites the pinned file descriptor with random data, syncs, truncates, revalidates device/inode identity, and unlinks directly without Trash, cleanup history, restore, scheduling, or cancellation after execution begins. Its UI states that SSD/APFS physical recovery cannot be guaranteed and recommends FileVault for future protection. +- Smart Shredder is a separate neo-glow sidebar workspace for explicitly selected ordinary files. It rejects directories, symlinks, hard links, package contents, protected system roots, and files changed after review; requires an acknowledgement plus an exact typed phrase; then overwrites the pinned file descriptor with random data, syncs, truncates, revalidates device/inode identity, and unlinks directly without Trash, cleanup history, restore, scheduling, or cancellation after execution begins. During execution a focused, Reduce Motion-aware scene uses the file's real Quick Look preview, feeds it through the mechanism, splits it into 20 falling strips, and drives its progress from actual bytes written. Finalizing remains below 100%, failed files remain queued, and green success appears only after the core emits its post-unlink completion event. Its UI states that SSD/APFS physical recovery cannot be guaranteed and recommends FileVault for future protection. - Disk Analysis and Duplicate Finder intercept their Custom Folder segment before changing source state, present the native folder panel on the next main-actor turn, preserve the previous source on cancel, and activate the custom source only after a real folder selection. - 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. - Results now lists unavailable scan areas with localized names and exact paths, distinguishes missing optional folders from read failures, and links permission-related failures to the in-app Permissions screen. - Cleanup planning validates paths against category roots and moves accepted items to Trash instead of permanently deleting them. -- Applications has a separate safe uninstaller for direct third-party `.app` bundles in `/Applications` and `~/Applications`; native checkboxes support multi-selection, exact bundle-ID leftover choices remain isolated per app, one batch confirmation shows the reviewed count and size, and every app still moves first so its failed move cannot touch leftovers. +- Applications has a separate uninstaller for direct third-party `.app` bundles in `/Applications` and `~/Applications`; native checkboxes support multi-selection, exact bundle-ID leftover choices remain isolated per app, one batch confirmation shows the reviewed count and size, and every app still removes first so its failed removal cannot touch leftovers. Trash mode remains the default. Permanent mode is an explicit segmented choice that auto-includes every shown exact bundle-ID leftover and deletes directly without Trash or CleanMac restore history. - Results UI now has compact category groups, risk-aware item review, a selected-item detail panel, and locally persisted Trash history with restore actions. - Cleanup history uses a versioned atomic JSON store with private permissions, unique operation IDs, a 100-record cap, fail-closed decoding, multi-window read-merge-write updates, and visible persistence errors. Persisted restores revalidate a direct child of the current user's Trash and a canonical destination inside the saved category allowlist, then open every directory component without following symlinks and use descriptor-relative exclusive rename. - Restore logic refuses to overwrite existing original paths and reports missing Trash/original locations without deleting anything. - Scan UI has all/safe/review filters plus safe/review/clear selection presets. - Scan UI shows a modern animated activity surface during active scans, with Reduce Motion support and selected-area chips. +- Standard cleanup, Disk Analysis, Duplicate Finder, and Applications scanning share a modern orbit-style indicator. Its rotation, pulse, shimmer, and signal motion are isolated state-driven animations instead of a display-frame `TimelineView`, preserving the thermal optimization while avoiding the gray system spinner. - 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. - 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. @@ -68,22 +70,24 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - 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 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. +- System has a separate manual workspace for two non-file-deleting maintenance actions: freeing inactive memory through `/usr/sbin/purge` and flushing DNS cache through `/usr/bin/dscacheutil -flushcache` plus `/usr/bin/killall -HUP mDNSResponder`. Commands use exact fixed scripts, no user-provided shell interpolation, no scheduling, and visible success/partial/failure states. Because modern macOS rejects these operations from a normal user process, the actions use the macOS administrator authorization prompt only after the user clicks a button. The memory action also shows the same read-only VM usage gauge as the menu bar and records before/after snapshots so users can see whether usage visibly dropped, stayed roughly the same, or rose again. - When free disk capacity falls strictly below 10%, the menu bar shows an adaptive warning with the remaining space, recommends a scan, and opens Disk Analysis directly without starting it. A background check runs at launch and every 30 minutes; when macOS notifications are already allowed, it can send at most one warning per 24 hours. - 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.4.0` is the latest release and includes the verified arm64 ad-hoc ZIP and SHA-256 assets. It ships the low-disk warning, custom-folder picker fix, scan thermal optimization, Smart Shredder, and refreshed public screenshots. -- 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. +- Release packaging creates a clean unsigned/ad-hoc app, a compressed DMG with `CleanMac.app` and an `Applications` shortcut, a fallback ZIP, and portable SHA-256 files. It stages the DMG outside the Desktop-backed File Provider path, strictly verifies a fresh mounted image and ZIP extraction, and can optionally sign with Developer ID, enable hardened runtime, submit to Apple notary service, staple, and recreate both archives when credentials are configured. ## Unfinished Or Risky Parts - This Mac has `0 valid identities found`, so actual Developer ID signing/notarization cannot be performed locally yet; macOS Gatekeeper rejects the current ad-hoc zip as expected. - 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. -- Standard cleanup, duplicate removal, and application removal remain intentionally Trash-based. Permanent deletion exists only inside the isolated, explicitly armed Smart Shredder and is never part of scans, recommendations, scheduling, cleanup history, or restore. +- Standard cleanup and duplicate removal remain intentionally Trash-based. Application removal now has an explicit permanent mode for reviewed `.app` bundles and exact bundle-ID leftovers; it bypasses Trash and CleanMac restore history, so the UI keeps it separate from the default Trash mode. Smart Shredder remains the only best-effort overwrite workflow. - Smart Shredder cannot guarantee physical-media erasure on SSD/APFS because copy-on-write, snapshots/clones, asynchronous TRIM, and flash wear leveling may retain older blocks outside the selected file's current mapping. - 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. +- System maintenance commands depend on macOS administrator authorization. If the user cancels the system prompt or macOS rejects the operation, CleanMac reports the failure and does not retry in the background. - Notification delivery depends on the macOS notification permission for CleanMac; if permission is denied, scheduled scans still complete silently and the low-space warning remains available inside the menu-bar popover. - 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. diff --git a/roadmap.md b/roadmap.md index ad74a3b..18068c3 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,5 +1,131 @@ # Roadmap +- [x] ID: TASK-056 + Title: Real Smart Shredder destruction animation + Goal: Make irreversible deletion visibly follow the real overwrite and unlink operation instead of a decorative timer. + What to do: Emit byte progress from the secure core, load a Quick Look preview, feed it into a focused shredder scene, split it into 20 falling strips, preserve Reduce Motion, and show success only after actual unlink. + Files: secure shredder core/tests, `ShredderView`, `ShredderAnimationView`, localization, Loop docs + Definition of done: progress reflects bytes written; finalizing stays below 100%; failed removal never reports complete and remains queued; the whole animation fits the standard window; RU/EN and Reduce Motion paths remain usable. + Verification: five focused Shredder tests; full 51-test suite; localization lint/key parity; Debug build/launch; live disposable-file feed/fragment/success screenshots; release packaging/checksums; `git diff --check` + Priority: high + Impact: high + Risk: high + Effort: medium + Confidence: high + Score: high impact / high risk / medium + +- [x] ID: TASK-055 + Title: Monochrome Retina Applications sidebar icon + Goal: Keep the Applications mark recognizable without making it the only colored or visually broken sidebar icon. + What to do: Load Finder's system Applications icon, isolate its 36-pixel Retina representation as one 18-point template, tint it with the existing sidebar state color, and retain a monochrome fallback. + Files: `CleanMac/Views/SidebarView.swift`, Loop docs + Definition of done: one crisp App Store-style mark renders gray when unselected and white when selected; no stacked `.icns` variants, colored app icon, copied Apple asset, or blank fallback remains. + Verification: Debug build/launch; selected and unselected screenshots; full SwiftPM tests; release packaging/checksums; `git diff --check` + Priority: high + Impact: medium + Risk: low + Effort: small + Confidence: high + Score: medium impact / low risk / small + +- [x] ID: TASK-054 + Title: Safe stale Codex runtime review and native Applications icon + Goal: Explain the large `~/.cache` usage through a narrowly scoped cleanup preview and make Applications instantly recognizable in the sidebar. + What to do: Scan only old exact Codex installer runtime directories, protect the active runtime, revalidate before Trash, add review-only warnings and confirmation, migrate selected scan areas once, and render the installed system App Store icon through `NSWorkspace`. + Files: cleanup core models/policy/scanner/planner/tests, scan preferences, Results UI, Sidebar UI, localizations, Loop docs + Definition of done: only direct exact installer runtimes older than seven days are reported; the current runtime and unsafe variants are excluded; results remain unselected and confirmation-gated; measured size is accurate; the sidebar shows the real Retina App Store icon with a fallback. + Verification: full SwiftPM tests; real read-only runtime probe; localization lint/key parity; Debug build/launch; selected/unselected sidebar review; release packaging/checksums; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: medium + Confidence: high + Score: high impact / medium risk / medium + +- [x] ID: TASK-053 + Title: Modern scan progress and drag-to-Applications DMG + Goal: Restore polished scan motion everywhere and ship a standard macOS drag-install image beside the app. + What to do: Replace scan-only system spinners with one Reduce Motion-aware state-driven indicator, keep scan pages free of `TimelineView`, create and verify `dist/CleanMac.dmg` with `CleanMac.app` plus an `/Applications` shortcut, and include the DMG in CI/tag releases. + Files: scan progress views, `script/package_release.sh`, GitHub workflows, release docs, Loop docs + Definition of done: all four scan surfaces share the modern indicator without frame-driven page invalidation; the mounted DMG contains a strictly valid app and working Applications shortcut; DMG/ZIP checksums pass. + Verification: full SwiftPM tests; localization lint/key parity; Debug build/launch; release packaging; mounted-DMG structure/signature; checksums; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: medium + Confidence: high + Score: high impact / medium risk / medium + +- [x] ID: TASK-052 + Title: Visible memory cleanup result + Goal: Make Free Memory visibly explain whether memory usage changed, not just report an exit code. + What to do: Share the menu-bar VM memory metric with the System screen, show a live memory gauge, capture before/after snapshots around `/usr/sbin/purge`, and label freed/no-change/rose-again outcomes. + Files: `CleanMac/Support/StatusSystemMetrics.swift`, `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, localization files, Loop docs + Definition of done: the memory card shows live usage before action; Free Memory reports before/after percentages; the result explicitly says whether memory visibly dropped, stayed roughly the same, or rose; automated verification does not execute RAM/DNS commands. + Verification: Debug build; full SwiftPM tests; localization lint/key parity; `./script/build_and_run.sh --verify`; `git diff --check` + Priority: high + Impact: medium + Risk: low + Effort: small + Confidence: high + Score: medium impact / low risk / small + +- [x] ID: TASK-050 + Title: System maintenance actions + Goal: Add manual RAM and DNS cache maintenance without mixing it into automatic cleanup. + What to do: Add a System sidebar section, a fixed-path command service, buttons for `/usr/sbin/purge` and DNS cache flushing, localized status output, and documentation. + Files: `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, navigation/localization files, README, Loop docs + Definition of done: initial commands run only from explicit buttons, fixed paths, and normal user execution; UI reports success, partial success, unavailable commands, and failures; no RAM/DNS command is executed during verification. Administrator-backed execution is tracked separately in TASK-051. + Verification: full SwiftPM tests; localization lint/key parity; Debug build/launch; `git diff --check` + Priority: high + Impact: medium + Risk: medium + Effort: medium + Confidence: high + Score: medium impact / medium risk / medium + +- [x] ID: TASK-051 + Title: Administrator-backed system maintenance + Goal: Make RAM purge and DNS cache flush work on modern macOS when normal user execution is denied. + What to do: Switch the two System actions to fixed administrator-authorized scripts through the macOS password prompt, update status/UI copy, and keep verification non-destructive. + Files: `CleanMac/Support/SystemMaintenanceService.swift`, `CleanMac/Views/SystemMaintenanceView.swift`, localization files, README, Loop docs + Definition of done: `/usr/sbin/purge` and DNS flush commands are still explicit-only and fixed; macOS owns the admin prompt; no credentials are stored; cancellation/failure is reported; no RAM/DNS command is executed during automated verification. + Verification: Debug build; full SwiftPM tests; localization lint/key parity; `./script/build_and_run.sh --verify`; `git diff --check` + Priority: high + Impact: high + Risk: medium + Effort: small + Confidence: high + Score: high impact / medium risk / small + +- [x] ID: TASK-049 + Title: Shredder icon polish + Goal: Make the Shredder sidebar item read as a file-shredding tool instead of a terminal. + What to do: Replace the terminal SF Symbol with a system scissors icon in navigation and reuse it in the Shredder header mark. + Files: `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/ShredderView.swift`, Loop docs + Definition of done: the sidebar and Shredder header no longer use `terminal.fill`; the app builds and launches; no deletion behavior changes. + Verification: full SwiftPM tests; `./script/build_and_run.sh --verify`; `git diff --check` + Priority: medium + Impact: low + Risk: low + Effort: small + Confidence: high + Score: low impact / low risk / small + +- [x] ID: TASK-048 + Title: Application removal modes + Goal: Let the user choose between recoverable Trash removal and direct permanent removal for reviewed applications. + What to do: Add a core removal mode, expand exact bundle-ID leftovers, add a segmented mode selector in Applications, auto-include shown leftovers in permanent mode, localize the irreversible confirmation, and test the direct-delete path with disposable fixtures. + Files: `CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift`, `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift`, `CleanMac/Views/ApplicationsView.swift`, `CleanMac/*/Localizable.strings`, Loop docs + Definition of done: Trash mode remains default; permanent mode bypasses Trash only after explicit selection and confirmation; app removal still happens before leftovers; failed app removal leaves leftovers untouched; expanded leftover paths are exact bundle-ID paths only; no real app is removed during verification. + Verification: focused app-removal tests; full SwiftPM tests; localization lint/key parity; Debug build; `./script/build_and_run.sh --verify`; `git diff --check` + Priority: high + Impact: high + Risk: high + Effort: medium + Confidence: high + Score: high impact / high risk / medium + - [x] ID: TASK-047 Title: CleanMac v0.4.0 release Goal: Publish the verified thermal optimization and Smart Shredder as the next feature release. diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 3003ee0..4ad8935 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -14,6 +14,36 @@ APP_BUNDLE="$BUILD_DATA_DIR/Build/Products/$CONFIGURATION/$APP_NAME.app" APP_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME" ENTITLEMENTS_PATH="$ROOT_DIR/CleanMac/CleanMac.entitlements" +sanitize_app_bundle() { + local app_path="$1" + xattr -cr "$app_path" + if command -v SetFile >/dev/null 2>&1; then + SetFile -a bc "$app_path" 2>/dev/null || true + fi + xattr -dr com.apple.FinderInfo "$app_path" 2>/dev/null || true + 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 +} + cd "$ROOT_DIR" pkill -x "$APP_NAME" >/dev/null 2>&1 || true @@ -28,17 +58,12 @@ xcodebuild \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGN_IDENTITY="" -xattr -cr "$APP_BUNDLE" -xattr -dr com.apple.FinderInfo "$APP_BUNDLE" 2>/dev/null || true -xattr -dr com.apple.ResourceFork "$APP_BUNDLE" 2>/dev/null || true - -codesign \ +codesign_clean_bundle "$APP_BUNDLE" \ --force \ --options runtime \ --entitlements "$ENTITLEMENTS_PATH" \ - --sign - \ - "$APP_BUNDLE" -codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" + --sign - +codesign_clean_bundle "$APP_BUNDLE" --verify --deep --strict --verbose=2 open_app() { /usr/bin/open -n "$APP_BUNDLE" diff --git a/script/package_release.sh b/script/package_release.sh index bfc9dcf..1e69a56 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -37,6 +37,7 @@ if [[ "$NOTARIZE" == "1" || "$NOTARIZE" == "true" ]]; then fi ZIP_PATH="$DIST_DIR/$APP_NAME-$BUILD_ID-$ZIP_KIND.zip" +DMG_PATH="$DIST_DIR/$APP_NAME.dmg" NOTARY_ARGS=() sanitize_app_bundle() { @@ -89,6 +90,64 @@ verify_zip() ( codesign --verify --deep --strict --verbose=2 "$verify_root/$APP_NAME.app" ) +create_dmg() ( + local dmg_path="$1" + local dmg_root + dmg_root="$(mktemp -d "/private/tmp/cleanmac-dmg-root.XXXXXX")" + local dmg_app="$dmg_root/$APP_NAME.app" + + trap 'rm -rf "$dmg_root"' EXIT + + rm -f "$dmg_path" "$dmg_path.sha256" + COPYFILE_DISABLE=1 ditto --norsrc --noextattr "$DIST_APP" "$dmg_app" + sanitize_app_bundle "$dmg_app" + ln -s /Applications "$dmg_root/Applications" + + hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$dmg_root" \ + -ov \ + -format UDZO \ + "$dmg_path" >/dev/null + +) + +verify_dmg() ( + local dmg_path="$1" + local mount_dir + local mounted=0 + + mount_dir="$(mktemp -d "/private/tmp/cleanmac-dmg-verify.XXXXXX")" + + cleanup() { + if [[ "$mounted" == "1" ]]; then + hdiutil detach "$mount_dir" -force >/dev/null 2>&1 || true + fi + rm -rf "$mount_dir" + } + trap cleanup EXIT + + hdiutil verify "$dmg_path" >/dev/null + hdiutil attach "$dmg_path" -readonly -nobrowse -mountpoint "$mount_dir" >/dev/null + mounted=1 + + if [[ ! -d "$mount_dir/$APP_NAME.app" ]]; then + echo "error: DMG does not contain $APP_NAME.app" >&2 + exit 1 + fi + + if [[ ! -L "$mount_dir/Applications" || "$(readlink "$mount_dir/Applications")" != "/Applications" ]]; then + echo "error: DMG does not contain the Applications shortcut" >&2 + exit 1 + fi + + codesign --verify --deep --strict --verbose=2 "$mount_dir/$APP_NAME.app" + hdiutil detach "$mount_dir" >/dev/null + mounted=0 + rmdir "$mount_dir" + trap - EXIT +) + build_notary_args() { if [[ -n "$NOTARY_PROFILE" ]]; then NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE") @@ -168,13 +227,22 @@ fi # distributable ZIP from a fresh extraction below. sanitize_app_bundle "$DIST_APP" verify_zip "$ZIP_PATH" +create_dmg "$DMG_PATH" +verify_dmg "$DMG_PATH" ( cd "$(dirname "$ZIP_PATH")" shasum -a 256 "$(basename "$ZIP_PATH")" ) > "$ZIP_PATH.sha256" +( + cd "$(dirname "$DMG_PATH")" + shasum -a 256 "$(basename "$DMG_PATH")" +) > "$DMG_PATH.sha256" + echo "Created:" echo " $DIST_APP" +echo " $DMG_PATH" +echo " $DMG_PATH.sha256" echo " $ZIP_PATH" echo " $ZIP_PATH.sha256" diff --git a/trace.md b/trace.md index 821b7c4..814777e 100644 --- a/trace.md +++ b/trace.md @@ -155,3 +155,52 @@ Append-only trace of failures, restarts, and judgment divergences. - Cause: File Provider can mutate the Documents-backed convenience copy after the packaging script sanitizes and signs it. - Fix: treated the fresh ZIP extraction as the distribution source of truth, matching the packaging contract, and repeated checksum, version, architecture, and strict signature verification on both a clean local extraction and a clean GitHub asset download. - Status: resolved for the release artifact; both fresh extractions are valid, while the mutable convenience app is not used as release evidence. + +## 2026-07-13 - TASK-048 - Debug launch signing metadata race + +- Symptom: the first `./script/build_and_run.sh --verify` pass built Debug successfully, then strict ad-hoc signature verification rejected `com.apple.FinderInfo` on the generated app bundle. +- Cause: the same local Finder/File Provider metadata race documented for release packaging can reattach Finder metadata between bundle sanitization, signing, and verification. +- Fix: hardened `script/build_and_run.sh` with the same bounded sanitize-and-retry wrapper used by release packaging, including `SetFile -a bc` when available. +- Status: resolved; the repeated `./script/build_and_run.sh --verify` pass produced a valid signed Debug app and launched CleanMac. + +## 2026-07-13 - TASK-051 - System maintenance permission denial + +- Symptom: Free Memory reported `/usr/sbin/purge` exit code 1 with `Operation not permitted`; Flush DNS could partially fail when signalling `mDNSResponder`. +- Cause: modern macOS can deny these maintenance commands to a normal app process even though the paths exist. +- Fix: System maintenance now runs only fixed absolute scripts through the macOS administrator authorization prompt after the user clicks a button, with no stored credentials, privileged helper, scheduling, or user-provided shell input. +- Status: resolved in code and verified by build/test checks; the actual RAM purge and DNS flush were intentionally not executed during automated verification. + +## 2026-07-13 - TASK-053 - DMG inherited File Provider metadata + +- Symptom: the first DMG was created, but strict verification of `CleanMac.app` inside the mounted image rejected `com.apple.FinderInfo`, so checksum generation correctly did not run. +- Cause: the first DMG staging folder lived under the Desktop-backed repository `dist/`, where File Provider could reattach metadata between sanitization and image creation. +- Fix: stage the DMG in `/private/tmp`, copy without resource forks or extended attributes, sanitize there, then create and verify the read-only image from that isolated copy. +- Status: resolved; the repeated package run verifies the DMG checksum, mounted app signature, and `/Applications` shortcut before writing release checksums. + +## 2026-07-13 - TASK-054 - Unavailable App Store SF Symbol + +- Symptom: the Applications sidebar icon was blank with `appstore`, while letter-based and hand-drawn substitutes did not resemble the native App Store artwork. +- Cause: `appstore` is not available as an SF Symbol on the current macOS, so `NSImage(systemSymbolName:)` returns `nil`. +- Fix: load the installed App Store application icon through `NSWorkspace`, render the Retina `NSImage` directly in SwiftUI, and keep `square.stack.3d.up.fill` only as a fallback when App Store is unavailable. +- Status: resolved; live selected and unselected sidebar screenshots show the native App Store icon clearly. + +## 2026-07-13 - TASK-054 - Runtime package descendants were undercounted + +- Symptom: the first stale-runtime probe measured less space than `du` for an old Codex installer directory. +- Cause: the shared scanner skipped package descendants, but Codex runtimes contain package-shaped directories whose contents still consume disk space. +- Fix: enumerate package descendants only for the narrowly scoped stale-runtime category and raise its dedicated safety cap to 100,000 descendants while preserving the existing cap and package behavior for every other category. +- Status: resolved; the real read-only probe reports two eligible directories totaling 1,772,158,976 bytes and excludes `codex-primary-runtime`. + +## 2026-07-13 - TASK-055 - Finder Applications icon rendered as stripes + +- Symptom: the full App Store icon was the only colored sidebar item; replacing it with the complete system `SidebarApplicationsFolder.icns` made SwiftUI composite several bitmap representations into a striped mark. +- Cause: the Finder `.icns` contains eight point/scale representations, and passing that multi-representation `NSImage` through the resizable template path did not select one stable bitmap. +- Fix: select the exact 36×36 Retina representation, create a single 18-point `NSImage`, mark it as a template, and tint it with the existing selected/unselected icon color. +- Status: resolved; live screenshots show one crisp gray mark when unselected and the same mark in white when selected. + +## 2026-07-13 - TASK-056 - Shredder animation clipped in the standard window + +- Symptom: the first live destruction-animation pass started below the static Shredder header, warning, and queue controls, so the lower fragments, progress, and completion state were clipped in the standard window. +- Cause: the normal workspace panels remained mounted while an irreversible operation was running, leaving too little vertical space for the complete focused animation scene. +- Fix: while an animation session exists, replace the normal Shredder content with one stable focused operation view that contains the Quick Look preview, mechanism, falling strips, real progress, and final state. +- Status: resolved; disposable-file screenshots show the complete scene at the 98% finalizing state and at the 100% post-unlink success state without clipping. diff --git a/verification.md b/verification.md index 93e3e1b..2034fee 100644 --- a/verification.md +++ b/verification.md @@ -53,12 +53,14 @@ | 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 | +| Stale Codex installer runtimes | Exact temporary fixtures plus a real read-only `~/.cache/codex-runtimes` probe | Codex runtime cleanup category, path policy, result confirmation, or selected-area migration changes | Only direct exact `codex-runtime-install-[A-Za-z0-9]+` directories older than seven days appear; `codex-primary-runtime`, recent, nested, malformed, and symlink paths stay excluded; results are review-only and unselected; planner revalidation accepts only unchanged eligible directories | Run focused stale-runtime scanner/planner tests, confirm the real probe size without executing cleanup, and inspect Results with Safe Mode enabled | | 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 remains responsive without frame-driven layout invalidation; 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 | -| Scan thermal load | Repeated `ps` samples plus an 8-second `sample` capture during the same Home Disk Analysis source | Progress visuals, progress stream buffering, or long scan task priority changes | CleanMac stays materially below the 111–124% CPU baseline; the main thread does not continuously relayout; the worker reports utility QoS; progress and Cancel remain functional | Confirm no `TimelineView` remains in scan progress views, then compare stack samples and stop the read-only analysis | +| Application removal | Temporary fake `.app` fixtures with injected Trash/permanent handlers plus read-only UI review | Application scanner, removal policy, multi-selection, removal mode, or Applications UI changes | Trash mode remains default; permanent mode bypasses Trash only after explicit selection; app target is removed first; failed app removal 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/permanent confirmation without accepting it | +| System maintenance | Command existence checks plus read-only UI/build review | RAM purge, DNS cache flush, System section navigation, administrator authorization, command status UI, or memory result metrics change | Buttons are explicit-only; commands use fixed absolute scripts through the macOS administrator prompt; memory shows live and before/after read-only VM stats; UI has running/success/partial/failure/unavailable states; app builds and launches without executing the maintenance actions | Confirm `/usr/sbin/purge`, `/usr/bin/dscacheutil`, `/usr/bin/killall`, and `/usr/bin/osascript` exist; inspect the memory gauge without clicking RAM/DNS buttons unless the user explicitly asks to run them | +| 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; the shared modern scan indicator remains responsive and Reduce Motion-aware without frame-driven page invalidation; 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 | +| Scan thermal load | Repeated `ps` samples plus an 8-second `sample` capture during the same Home Disk Analysis source | Progress visuals, progress stream buffering, or long scan task priority changes | CleanMac stays materially below the 111–124% CPU baseline; the main thread does not continuously relayout; modern progress motion is isolated to small layers; the worker reports utility QoS; progress and Cancel remain functional | Confirm no `TimelineView` remains in scan progress views, then compare stack samples and stop the read-only analysis | | 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 | -| Smart Shredder | Disposable regular-file fixtures plus non-destructive EN/RU and light/dark UI review | Shredder validation, overwrite/unlink execution, confirmation gating, navigation, limitations copy, or neo-glow styling changes | Only unchanged single-link regular files pass review; directories, symlinks, hard links, package contents, protected roots/aliases, and replaced files fail closed; the file is overwritten before direct unlink; both acknowledgement and exact phrase are required; the UI never promises guaranteed SSD/APFS physical erasure | Run `SecureFileShredderTests` and the full core suite; in the app inspect queue and armed confirmation but never accept the final action against a real user file | -| 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` | +| Smart Shredder | Disposable regular-file fixtures plus EN/RU, light/dark, and Reduce Motion UI review | Shredder validation, overwrite/unlink execution, real progress, destruction animation, confirmation gating, navigation, limitations copy, or neo-glow styling changes | Only unchanged single-link regular files pass review; unsafe or replaced files fail closed; both acknowledgement and exact phrase are required; actual completed bytes drive progress; Quick Look preview feeds into 20 strips; finalizing stays below 100%; failed removal never emits complete or leaves the queue; green success appears only after unlink; the focused scene fits the standard window; the UI never promises guaranteed SSD/APFS physical erasure | Run `SecureFileShredderTests` and the full core suite; exercise the final action only against a disposable file created under `/private/tmp`, confirm it exists before the run and is absent after success, and never select a real user file | +| Release package | `./script/package_release.sh` | Packaging, release, or CI artifact changes | `dist/CleanMac.app`, `dist/CleanMac.dmg`, fallback ZIP, and both `.sha256` files are created; a read-only mounted DMG contains the app plus `Applications -> /Applications`; mounted app signature and checksums pass | Inspect `build/XcodeData/Build/Products/Release`, then mount the DMG under `/private/tmp` and validate its contents | | 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 |