From 4fcbcfa6ea221bd0da5535cbf19480aad8d99718 Mon Sep 17 00:00:00 2001 From: John Silver Date: Mon, 13 Jul 2026 07:22:43 +0500 Subject: [PATCH] Optimize scan performance and add smart shredder --- CleanMac/Models/CleanMacModels.swift | 3 + .../Support/ShredderWorkspaceService.swift | 20 + .../Views/DiskAnalysisProgressIndicator.swift | 62 +- CleanMac/Views/DiskAnalysisView.swift | 7 +- CleanMac/Views/DuplicateFinderView.swift | 7 +- CleanMac/Views/MainWindowView.swift | 11 +- CleanMac/Views/ScanActivityView.swift | 141 +--- CleanMac/Views/ShredderView.swift | 601 ++++++++++++++++++ CleanMac/en.lproj/Localizable.strings | 45 ++ CleanMac/ru.lproj/Localizable.strings | 45 ++ .../CleanMacCore/SecureFileShredder.swift | 270 ++++++++ .../SecureFileShredderTests.swift | 121 ++++ README.md | 14 +- contract.md | 69 +- progress.md | 20 + project-analysis.md | 5 +- roadmap.md | 28 + trace.md | 14 + verification.md | 4 +- 19 files changed, 1286 insertions(+), 201 deletions(-) create mode 100644 CleanMac/Support/ShredderWorkspaceService.swift create mode 100644 CleanMac/Views/ShredderView.swift create mode 100644 CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift create mode 100644 CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift diff --git a/CleanMac/Models/CleanMacModels.swift b/CleanMac/Models/CleanMacModels.swift index 2f3faac..81e2c10 100644 --- a/CleanMac/Models/CleanMacModels.swift +++ b/CleanMac/Models/CleanMacModels.swift @@ -7,6 +7,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case results case diskAnalysis case duplicates + case shredder case applications case settings @@ -19,6 +20,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .results: L.t("section.results") case .diskAnalysis: L.t("section.diskAnalysis") case .duplicates: L.t("section.duplicates") + case .shredder: L.t("section.shredder") case .applications: L.t("section.applications") case .settings: L.t("section.settings") } @@ -31,6 +33,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .results: "checklist" case .diskAnalysis: "chart.pie.fill" case .duplicates: "square.on.square" + case .shredder: "terminal.fill" case .applications: "app.badge.checkmark" case .settings: "gearshape" } diff --git a/CleanMac/Support/ShredderWorkspaceService.swift b/CleanMac/Support/ShredderWorkspaceService.swift new file mode 100644 index 0000000..23b52ab --- /dev/null +++ b/CleanMac/Support/ShredderWorkspaceService.swift @@ -0,0 +1,20 @@ +import AppKit + +@MainActor +enum ShredderWorkspaceService { + static func chooseFiles() -> [URL] { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.canCreateDirectories = false + panel.treatsFilePackagesAsDirectories = false + panel.prompt = L.t("shredder.picker.confirm") + panel.message = L.t("shredder.picker.message") + + guard panel.runModal() == .OK else { + return [] + } + return panel.urls.map(\.standardizedFileURL) + } +} diff --git a/CleanMac/Views/DiskAnalysisProgressIndicator.swift b/CleanMac/Views/DiskAnalysisProgressIndicator.swift index 5ffc24c..03413bd 100644 --- a/CleanMac/Views/DiskAnalysisProgressIndicator.swift +++ b/CleanMac/Views/DiskAnalysisProgressIndicator.swift @@ -4,55 +4,33 @@ struct DiskAnalysisProgressIndicator: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - TimelineView(.animation(minimumInterval: 1 / 30, paused: reduceMotion)) { timeline in - let phase = reduceMotion ? 0.15 : timeline.date.timeIntervalSinceReferenceDate - let rotation = Angle.degrees(phase.truncatingRemainder(dividingBy: 2.4) / 2.4 * 360) - let pulse = reduceMotion ? 1.0 : 0.92 + (sin(phase * 3.2) + 1) * 0.04 - - ZStack { - Circle() - .fill( - RadialGradient( - colors: [ - Color.accentColor.opacity(0.2), - Color.cyan.opacity(0.08), - .clear - ], - center: .center, - startRadius: 2, - endRadius: 30 - ) + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.accentColor.opacity(0.2), + Color.cyan.opacity(0.08), + .clear + ], + center: .center, + startRadius: 2, + endRadius: 30 ) - .scaleEffect(pulse) - - Circle() - .stroke(Color.accentColor.opacity(0.14), lineWidth: 5) + ) - Circle() - .trim(from: 0.06, to: 0.7) - .stroke( - AngularGradient( - colors: [.blue, .cyan, .purple, .blue], - center: .center - ), - style: StrokeStyle(lineWidth: 5, lineCap: .round) - ) - .rotationEffect(rotation) - .shadow(color: Color.accentColor.opacity(0.32), radius: 5) - - Circle() - .trim(from: 0.08, to: 0.38) - .stroke( - Color.white.opacity(0.8), - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .rotationEffect(.degrees(-rotation.degrees * 0.72)) + 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) - .scaleEffect(pulse) + } else { + ProgressView() + .controlSize(.regular) + .tint(.accentColor) } } .frame(width: 58, height: 58) diff --git a/CleanMac/Views/DiskAnalysisView.swift b/CleanMac/Views/DiskAnalysisView.swift index 3047717..420f128 100644 --- a/CleanMac/Views/DiskAnalysisView.swift +++ b/CleanMac/Views/DiskAnalysisView.swift @@ -644,8 +644,11 @@ struct DiskAnalysisView: View { problemMessage = nil let minimumLargeFileSize = LargeFileThreshold.megabytes50.bytes - let progressChannel = AsyncStream.makeStream(of: DiskAnalysisProgress.self) - let worker = Task.detached(priority: .userInitiated) { + let progressChannel = AsyncStream.makeStream( + of: DiskAnalysisProgress.self, + bufferingPolicy: .bufferingNewest(1) + ) + let worker = Task.detached(priority: .utility) { defer { progressChannel.continuation.finish() } return try DiskAnalyzer().scan( root: rootURL, diff --git a/CleanMac/Views/DuplicateFinderView.swift b/CleanMac/Views/DuplicateFinderView.swift index 543f759..69fb9e8 100644 --- a/CleanMac/Views/DuplicateFinderView.swift +++ b/CleanMac/Views/DuplicateFinderView.swift @@ -490,8 +490,11 @@ struct DuplicateFinderView: View { ) let mode: DuplicateScanMode = includeLargeFiles ? .includeLargeFiles : .standard - let progressChannel = AsyncStream.makeStream(of: DuplicateScanProgress.self) - let worker = Task.detached(priority: .userInitiated) { + let progressChannel = AsyncStream.makeStream( + of: DuplicateScanProgress.self, + bufferingPolicy: .bufferingNewest(1) + ) + let worker = Task.detached(priority: .utility) { defer { progressChannel.continuation.finish() } return try await DuplicateFinder().scan( root: rootURL, diff --git a/CleanMac/Views/MainWindowView.swift b/CleanMac/Views/MainWindowView.swift index af8d75d..d024f06 100644 --- a/CleanMac/Views/MainWindowView.swift +++ b/CleanMac/Views/MainWindowView.swift @@ -44,7 +44,9 @@ struct MainWindowView: View { .navigationTitle(selectedSection.title) .toolbar { ToolbarItemGroup { - if selectedSection != .diskAnalysis, selectedSection != .duplicates { + if selectedSection != .diskAnalysis, + selectedSection != .duplicates, + selectedSection != .shredder { Button { runScan() } label: { @@ -132,6 +134,8 @@ struct MainWindowView: View { DiskAnalysisView() case .duplicates: DuplicateFinderView() + case .shredder: + ShredderView() case .applications: ApplicationsView() case .settings: @@ -196,7 +200,10 @@ struct MainWindowView: View { let scanStartedAt = Date() Task { - let progressChannel = AsyncStream.makeStream(of: CleanupScanProgress.self) + let progressChannel = AsyncStream.makeStream( + of: CleanupScanProgress.self, + bufferingPolicy: .bufferingNewest(1) + ) let scanTask = Task.detached(priority: .userInitiated) { let report = CleanupScanner().scan(categories: categories) { progress in progressChannel.continuation.yield(progress) diff --git a/CleanMac/Views/ScanActivityView.swift b/CleanMac/Views/ScanActivityView.swift index 15ba5b0..9ef0712 100644 --- a/CleanMac/Views/ScanActivityView.swift +++ b/CleanMac/Views/ScanActivityView.swift @@ -5,8 +5,6 @@ struct ScanActivityView: View { let selectedAreas: [CleanupArea] let progress: CleanupScanProgress? - @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var visibleAreas: [CleanupArea] { Array(selectedAreas.prefix(5)) } @@ -20,70 +18,58 @@ struct ScanActivityView: View { } var body: some View { - TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30.0)) { timeline in - let elapsed = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate - - InfoPanel { - ViewThatFits(in: .horizontal) { - horizontalLayout(elapsed: elapsed) - verticalLayout(elapsed: elapsed) - } + InfoPanel { + ViewThatFits(in: .horizontal) { + horizontalLayout + verticalLayout } - .transition(.opacity.combined(with: .scale(scale: 0.98))) } + .transition(.opacity.combined(with: .scale(scale: 0.98))) } - private func horizontalLayout(elapsed: TimeInterval) -> some View { + private var horizontalLayout: some View { HStack(alignment: .center, spacing: 18) { - ScanOrbitalIndicator( - elapsed: elapsed, - reduceMotion: reduceMotion, - progress: progressFraction - ) + ScanOrbitalIndicator(progress: progressFraction) - content(elapsed: elapsed) + content .frame(maxWidth: .infinity, alignment: .leading) - ScanSignalBars(elapsed: elapsed, reduceMotion: reduceMotion) + ScanSignalBars(progress: progressFraction) .frame(width: 76) } .frame(maxWidth: .infinity, alignment: .leading) } - private func verticalLayout(elapsed: TimeInterval) -> some View { + private var verticalLayout: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 14) { - ScanOrbitalIndicator( - elapsed: elapsed, - reduceMotion: reduceMotion, - progress: progressFraction - ) - header(elapsed: elapsed) + ScanOrbitalIndicator(progress: progressFraction) + header } - progressTrack(elapsed: elapsed) + progressTrack scanMetrics areaRail } .frame(maxWidth: .infinity, alignment: .leading) } - private func content(elapsed: TimeInterval) -> some View { + private var content: some View { VStack(alignment: .leading, spacing: 12) { - header(elapsed: elapsed) - progressTrack(elapsed: elapsed) + header + progressTrack scanMetrics areaRail } } - private func header(elapsed: TimeInterval) -> some View { + private var header: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Label(L.t("scan.animation.title"), systemImage: "sparkles") .font(.headline) - Text(phaseText(elapsed: elapsed)) + Text(phaseText) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) @@ -100,7 +86,7 @@ struct ScanActivityView: View { } } - private func progressTrack(elapsed: TimeInterval) -> some View { + private var progressTrack: some View { GeometryReader { proxy in let trackWidth = proxy.size.width let filledWidth = max(8, trackWidth * progressFraction) @@ -118,21 +104,8 @@ struct ScanActivityView: View { ) ) .frame(width: filledWidth) - .overlay(alignment: .leading) { - if !reduceMotion { - Capsule() - .fill( - LinearGradient( - colors: [.clear, .white.opacity(0.34), .clear], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: 54) - .offset(x: shimmerOffset(elapsed: elapsed, width: filledWidth)) - } - } .clipShape(Capsule()) + .animation(.easeOut(duration: 0.2), value: progressFraction) } } .frame(height: 8) @@ -233,16 +206,9 @@ struct ScanActivityView: View { return L.f("scan.animation.areaProgress", currentNumber, progress.totalCategoryCount) } - private func phaseText(elapsed: TimeInterval) -> String { + private var phaseText: String { guard let progress else { - let phases = [ - L.t("scan.animation.phase.metadata"), - L.t("scan.animation.phase.sizes"), - L.t("scan.animation.phase.risks"), - L.t("scan.animation.phase.summary") - ] - let index = Int(elapsed / 1.15) % phases.count - return phases[index] + return L.t("scan.animation.phase.metadata") } switch progress.phase { @@ -290,35 +256,11 @@ struct ScanActivityView: View { return name.isEmpty ? path : name } - private func shimmerOffset(elapsed: TimeInterval, width: CGFloat) -> CGFloat { - guard width > 54 else { - return 0 - } - let cycle = elapsed.truncatingRemainder(dividingBy: 1.45) / 1.45 - return CGFloat(cycle) * (width + 54) - 54 - } } private struct ScanOrbitalIndicator: View { - let elapsed: TimeInterval - let reduceMotion: Bool let progress: Double - private var rotation: Angle { - .degrees(reduceMotion ? 28 : elapsed * 135) - } - - private var reverseRotation: Angle { - .degrees(reduceMotion ? -16 : -elapsed * 82) - } - - private var pulse: CGFloat { - guard !reduceMotion else { - return 1 - } - return 1 + CGFloat((sin(elapsed * 2.2) + 1) * 0.035) - } - var body: some View { ZStack { Circle() @@ -328,7 +270,6 @@ private struct ScanOrbitalIndicator: View { Circle() .stroke(.blue.opacity(0.13), lineWidth: 12) .frame(width: 60, height: 60) - .scaleEffect(pulse) Circle() .trim(from: 0, to: max(0.04, progress)) @@ -342,29 +283,11 @@ private struct ScanOrbitalIndicator: View { ) .frame(width: 64, height: 64) .rotationEffect(.degrees(-90)) + .animation(.easeOut(duration: 0.2), value: progress) - Circle() - .trim(from: 0.04, to: 0.38) - .stroke( - AngularGradient( - colors: [.blue.opacity(0.12), .cyan, .green.opacity(0.82)], - center: .center - ), - style: StrokeStyle(lineWidth: 3, lineCap: .round) - ) - .frame(width: 54, height: 54) - .rotationEffect(rotation) - - Circle() - .trim(from: 0.62, to: 0.78) - .stroke(.blue.opacity(0.34), style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .frame(width: 44, height: 44) - .rotationEffect(reverseRotation) - - Image(systemName: "magnifyingglass") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(.blue) - .symbolRenderingMode(.hierarchical) + ProgressView() + .controlSize(.small) + .tint(.blue) } .frame(width: 82, height: 82) .accessibilityHidden(true) @@ -372,8 +295,7 @@ private struct ScanOrbitalIndicator: View { } private struct ScanSignalBars: View { - let elapsed: TimeInterval - let reduceMotion: Bool + let progress: Double private let barCount = 5 @@ -383,6 +305,7 @@ private struct ScanSignalBars: View { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(barColor(for: index)) .frame(width: 8, height: barHeight(for: index)) + .animation(.easeOut(duration: 0.2), value: progress) } } .frame(height: 44, alignment: .bottom) @@ -390,11 +313,9 @@ private struct ScanSignalBars: View { } private func barHeight(for index: Int) -> CGFloat { - guard !reduceMotion else { - return CGFloat(16 + index * 4) - } - let wave = (sin(elapsed * 3.4 + Double(index) * 0.82) + 1) / 2 - return CGFloat(12 + wave * 28) + let clampedProgress = min(max(progress, 0.08), 1) + let step = Double(index + 1) / Double(barCount) + return CGFloat(12 + 28 * clampedProgress * step) } private func barColor(for index: Int) -> Color { diff --git a/CleanMac/Views/ShredderView.swift b/CleanMac/Views/ShredderView.swift new file mode 100644 index 0000000..2959450 --- /dev/null +++ b/CleanMac/Views/ShredderView.swift @@ -0,0 +1,601 @@ +import CleanMacCore +import SwiftUI + +struct ShredderView: View { + @Environment(\.colorScheme) private var colorScheme + + @State private var candidates: [SecureDeletionCandidate] = [] + @State private var isChoosingFiles = false + @State private var isShredding = false + @State private var completedCount = 0 + @State private var statusMessage: String? + @State private var problemMessage: String? + @State private var showingConfirmation = false + @State private var confirmationText = "" + @State private var acknowledgedLimitations = false + + private var palette: NeoShredderPalette { + NeoShredderPalette(colorScheme: colorScheme) + } + + private var totalSizeBytes: Int64 { + candidates.reduce(0) { $0 + $1.sizeBytes } + } + + private var confirmationPhrase: String { + L.t("shredder.confirm.phrase") + } + + private var isConfirmationValid: Bool { + acknowledgedLimitations && confirmationText == confirmationPhrase + } + + var body: some View { + PageContainer { + VStack(alignment: .leading, spacing: 16) { + hackerHeader + limitationPanel + queueControls + queuePanel + + if let statusMessage { + feedbackPanel(message: statusMessage, isProblem: false) + } + + if let problemMessage { + feedbackPanel(message: problemMessage, isProblem: true) + } + } + } + .sheet(isPresented: $showingConfirmation) { + confirmationSheet + } + .onChange(of: showingConfirmation) { _, isPresented in + if isPresented { + confirmationText = "" + acknowledgedLimitations = false + } + } + } + + private var hackerHeader: some View { + NeoShredderPanel(palette: palette, isGlowing: true) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 18) { + terminalMark + headerCopy + Spacer(minLength: 12) + stateStack + } + + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 14) { + terminalMark + headerCopy + } + stateStack + } + } + } + } + + private var terminalMark: some View { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(palette.inset) + .overlay { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(palette.cyan.opacity(0.5)) + } + .shadow(color: palette.glow.opacity(0.8), radius: 16) + + Image(systemName: "terminal.fill") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(palette.cyan) + } + .frame(width: 58, height: 58) + .accessibilityHidden(true) + } + + private var headerCopy: some View { + VStack(alignment: .leading, spacing: 5) { + Text(L.t("shredder.header.code")) + .font(.caption.monospaced().weight(.bold)) + .tracking(1.2) + .foregroundStyle(palette.cyan) + + Text(L.t("shredder.title")) + .font(.largeTitle.monospaced().bold()) + .foregroundStyle(palette.textPrimary) + + Text(L.t("shredder.subtitle")) + .foregroundStyle(palette.textMuted) + } + } + + private var stateStack: some View { + VStack(alignment: .trailing, spacing: 8) { + HStack(spacing: 7) { + stateChip(L.t("shredder.state.local"), color: palette.cyan) + stateChip(L.t("shredder.state.noTrash"), color: palette.danger) + stateChip(L.t("shredder.state.failClosed"), color: palette.success) + } + + Text(L.f("shredder.queue.summary", candidates.count, CleanMacFormatters.bytes(totalSizeBytes))) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(palette.textMuted) + } + } + + private func stateChip(_ title: String, color: Color) -> some View { + Text(title) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(color.opacity(0.1), in: Capsule()) + .overlay { + Capsule().strokeBorder(color.opacity(0.35)) + } + } + + private var limitationPanel: some View { + NeoShredderPanel(palette: palette, tone: .warning) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.shield.fill") + .font(.title2) + .foregroundStyle(palette.danger) + .symbolRenderingMode(.hierarchical) + + VStack(alignment: .leading, spacing: 6) { + Text(L.t("shredder.warning.title")) + .font(.headline.monospaced()) + .foregroundStyle(palette.textPrimary) + + Text(L.t("shredder.warning.direct")) + .foregroundStyle(palette.textPrimary) + + Text(L.t("shredder.warning.ssd")) + .font(.callout) + .foregroundStyle(palette.textMuted) + } + + Spacer(minLength: 0) + } + } + } + + private var queueControls: some View { + NeoShredderPanel(palette: palette) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + queueDescription + Spacer(minLength: 12) + actionButtons + } + + VStack(alignment: .leading, spacing: 12) { + queueDescription + actionButtons + } + } + } + } + + private var queueDescription: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L.t("shredder.queue.title")) + .font(.headline.monospaced()) + .foregroundStyle(palette.textPrimary) + Text(L.t("shredder.queue.detail")) + .font(.callout) + .foregroundStyle(palette.textMuted) + } + } + + private var actionButtons: some View { + HStack(spacing: 10) { + Button { + chooseFiles() + } label: { + Label(L.t("shredder.add"), systemImage: "plus.rectangle.on.folder") + } + .buttonStyle(NeoShredderButtonStyle(palette: palette)) + .disabled(isChoosingFiles || isShredding) + + Button { + showingConfirmation = true + } label: { + if isShredding { + Label( + L.f("shredder.progress", completedCount, candidates.count), + systemImage: "hourglass" + ) + } else { + Label(L.t("shredder.execute"), systemImage: "bolt.shield.fill") + } + } + .buttonStyle(NeoShredderButtonStyle(palette: palette, isDanger: true)) + .disabled(candidates.isEmpty || isShredding || isChoosingFiles) + } + } + + private var queuePanel: some View { + NeoShredderPanel(palette: palette, isGlowing: !candidates.isEmpty) { + if candidates.isEmpty { + ContentUnavailableView( + L.t("shredder.empty.title"), + systemImage: "doc.badge.plus", + description: Text(L.t("shredder.empty.message")) + ) + .foregroundStyle(palette.textPrimary) + .frame(maxWidth: .infinity, minHeight: 220) + } else { + VStack(spacing: 0) { + ForEach(candidates) { candidate in + candidateRow(candidate) + + if candidate.id != candidates.last?.id { + Divider().overlay(palette.line) + } + } + } + } + } + } + + private func candidateRow(_ candidate: SecureDeletionCandidate) -> some View { + HStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.title3) + .foregroundStyle(candidate.isAPFS ? palette.danger : palette.cyan) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(candidate.name) + .font(.headline.monospaced()) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + + Text(candidate.isAPFS ? L.t("shredder.file.bestEffort") : L.t("shredder.file.overwrite")) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(candidate.isAPFS ? palette.danger : palette.cyan) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + (candidate.isAPFS ? palette.danger : palette.cyan).opacity(0.1), + in: Capsule() + ) + } + + Text(candidate.path) + .font(.caption.monospaced()) + .foregroundStyle(palette.textMuted) + .lineLimit(1) + .truncationMode(.middle) + + Text(L.f( + "shredder.file.metadata", + CleanMacFormatters.bytes(candidate.sizeBytes), + candidate.fileSystemName + )) + .font(.caption2.monospaced()) + .foregroundStyle(palette.textMuted) + } + + Spacer(minLength: 8) + + Button { + candidates.removeAll { $0.id == candidate.id } + statusMessage = nil + problemMessage = nil + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .foregroundStyle(palette.textMuted) + .help(L.t("shredder.remove")) + .disabled(isShredding) + } + .padding(.vertical, 10) + } + + private func feedbackPanel(message: String, isProblem: Bool) -> some View { + NeoShredderPanel(palette: palette, tone: isProblem ? .warning : .success) { + Label( + message, + systemImage: isProblem ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" + ) + .foregroundStyle(isProblem ? palette.danger : palette.success) + } + } + + private var confirmationSheet: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 12) { + Image(systemName: "bolt.trianglebadge.exclamationmark.fill") + .font(.system(size: 30)) + .foregroundStyle(palette.danger) + .shadow(color: palette.danger.opacity(0.45), radius: 12) + + VStack(alignment: .leading, spacing: 3) { + Text(L.t("shredder.confirm.title")) + .font(.title2.monospaced().bold()) + Text(L.f( + "shredder.confirm.summary", + candidates.count, + CleanMacFormatters.bytes(totalSizeBytes) + )) + .foregroundStyle(.secondary) + } + } + + Text(L.t("shredder.confirm.warning")) + .fixedSize(horizontal: false, vertical: true) + + Toggle(isOn: $acknowledgedLimitations) { + Text(L.t("shredder.confirm.acknowledge")) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 7) { + Text(L.f("shredder.confirm.type", confirmationPhrase)) + .font(.callout.weight(.semibold)) + + TextField(confirmationPhrase, text: $confirmationText) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + } + + HStack { + Button(L.t("button.cancel")) { + showingConfirmation = false + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(L.t("shredder.confirm.action"), role: .destructive) { + showingConfirmation = false + executeShredder() + } + .disabled(!isConfirmationValid) + } + } + .padding(24) + .frame(width: 560) + } + + private func chooseFiles() { + guard !isChoosingFiles, !isShredding else { return } + isChoosingFiles = true + statusMessage = nil + problemMessage = nil + + Task { @MainActor in + await Task.yield() + let urls = ShredderWorkspaceService.chooseFiles() + var acceptedCount = 0 + var rejectionMessages: [String] = [] + let inspector = SecureFileShredder() + + for url in urls { + do { + let candidate = try inspector.inspect(url: url) + guard !candidates.contains(where: { $0.id == candidate.id }) else { + continue + } + candidates.append(candidate) + acceptedCount += 1 + } catch { + rejectionMessages.append(L.f( + "shredder.error.file", + url.lastPathComponent, + errorReason(error) + )) + } + } + + isChoosingFiles = false + if acceptedCount > 0 { + statusMessage = L.f("shredder.status.added", acceptedCount) + } + if !rejectionMessages.isEmpty { + problemMessage = rejectionMessages.prefix(3).joined(separator: "\n") + } + } + } + + private func executeShredder() { + guard !candidates.isEmpty, !isShredding else { return } + let reviewedCandidates = candidates + isShredding = true + completedCount = 0 + statusMessage = nil + problemMessage = nil + + Task { + 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 + removedBytes += bytes + removedIDs.insert(candidate.id) + } catch { + failures.append(L.f( + "shredder.error.file", + candidate.name, + errorReason(error) + )) + } + completedCount += 1 + } + + candidates.removeAll { removedIDs.contains($0.id) } + isShredding = false + statusMessage = L.f( + "shredder.status.complete", + removedIDs.count, + CleanMacFormatters.bytes(removedBytes) + ) + if !failures.isEmpty { + problemMessage = failures.prefix(3).joined(separator: "\n") + } + } + } + + private func errorReason(_ error: Error) -> String { + guard let error = error as? SecureDeletionError else { + return L.t("shredder.error.generic") + } + + switch error { + case .pathUnavailable: + return L.t("shredder.error.unavailable") + case .protectedPath: + return L.t("shredder.error.protected") + case .packageContent: + return L.t("shredder.error.package") + case .symbolicLink: + return L.t("shredder.error.symlink") + case .notRegularFile: + return L.t("shredder.error.regularOnly") + case .multipleHardLinks: + return L.t("shredder.error.hardLink") + case .fileChanged: + return L.t("shredder.error.changed") + case .fileBusy: + return L.t("shredder.error.busy") + case .openFailed, .writeFailed, .syncFailed, .truncateFailed, .removeFailed: + return L.t("shredder.error.io") + } + } +} + +private enum NeoShredderTone { + case normal + case warning + case success +} + +private struct NeoShredderPanel: View { + let palette: NeoShredderPalette + var tone: NeoShredderTone = .normal + var isGlowing = false + let content: Content + + init( + palette: NeoShredderPalette, + tone: NeoShredderTone = .normal, + isGlowing: Bool = false, + @ViewBuilder content: () -> Content + ) { + self.palette = palette + self.tone = tone + self.isGlowing = isGlowing + self.content = content() + } + + private var borderColor: Color { + switch tone { + case .normal: isGlowing ? palette.cyan.opacity(0.45) : palette.line + case .warning: palette.danger.opacity(0.45) + case .success: palette.success.opacity(0.4) + } + } + + private var shadowColor: Color { + switch tone { + case .normal: isGlowing ? palette.glow : .clear + case .warning: palette.danger.opacity(0.18) + case .success: palette.success.opacity(0.16) + } + } + + var body: some View { + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(borderColor) + } + .shadow(color: palette.shadow, radius: 18, y: 10) + .shadow(color: shadowColor, radius: isGlowing ? 20 : 12) + } +} + +private struct NeoShredderButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + let palette: NeoShredderPalette + var isDanger = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(isDanger ? Color.white : palette.textPrimary) + .padding(.horizontal, 13) + .frame(minHeight: 34) + .background( + isDanger ? palette.danger : palette.inset, + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder((isDanger ? palette.danger : palette.cyan).opacity(0.55)) + } + .shadow( + color: isEnabled ? (isDanger ? palette.danger : palette.glow).opacity(0.55) : .clear, + radius: configuration.isPressed ? 6 : 12 + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + .opacity(isEnabled ? 1 : 0.45) + } +} + +private struct NeoShredderPalette { + let surface: Color + let inset: Color + let textPrimary: Color + let textMuted: Color + let line: Color + let cyan: Color + let glow: Color + let danger: Color + let success: Color + let shadow: Color + + init(colorScheme: ColorScheme) { + if colorScheme == .dark { + surface = Color(red: 0.125, green: 0.165, blue: 0.224) + inset = Color(red: 0.086, green: 0.122, blue: 0.176) + textPrimary = Color(red: 0.941, green: 0.961, blue: 1) + textMuted = Color(red: 0.576, green: 0.635, blue: 0.729) + line = Color.white.opacity(0.12) + cyan = Color(red: 0.439, green: 0.914, blue: 1) + glow = Color(red: 0.286, green: 0.643, blue: 1).opacity(0.42) + danger = Color(red: 1, green: 0.498, blue: 0.573) + success = Color(red: 0.259, green: 0.851, blue: 0.58) + shadow = Color.black.opacity(0.48) + } else { + surface = Color(red: 0.965, green: 0.973, blue: 0.984) + inset = Color(red: 0.89, green: 0.91, blue: 0.941) + textPrimary = Color(red: 0.122, green: 0.145, blue: 0.208) + textMuted = Color(red: 0.471, green: 0.514, blue: 0.596) + line = Color(red: 0.52, green: 0.58, blue: 0.67).opacity(0.2) + cyan = Color(red: 0.176, green: 0.49, blue: 0.984) + glow = Color(red: 0.247, green: 0.573, blue: 1).opacity(0.34) + danger = Color(red: 0.92, green: 0.22, blue: 0.31) + success = Color(red: 0.12, green: 0.66, blue: 0.4) + shadow = Color(red: 0.64, green: 0.69, blue: 0.78).opacity(0.28) + } + } +} diff --git a/CleanMac/en.lproj/Localizable.strings b/CleanMac/en.lproj/Localizable.strings index eb186ae..b8afc31 100644 --- a/CleanMac/en.lproj/Localizable.strings +++ b/CleanMac/en.lproj/Localizable.strings @@ -6,6 +6,7 @@ "section.results" = "Results"; "section.diskAnalysis" = "Disk Analysis"; "section.duplicates" = "Duplicates"; +"section.shredder" = "Shredder"; "section.applications" = "Applications"; "section.permissions" = "Permissions"; "section.settings" = "Settings"; @@ -521,6 +522,50 @@ "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."; +"shredder.header.code" = "SECURE_ERASE // OFFLINE"; +"shredder.title" = "Smart Shredder"; +"shredder.subtitle" = "Review regular files, then overwrite and unlink them directly."; +"shredder.state.local" = "LOCAL"; +"shredder.state.noTrash" = "NO TRASH"; +"shredder.state.failClosed" = "FAIL CLOSED"; +"shredder.queue.summary" = "%d file(s) · %@"; +"shredder.warning.title" = "POINT OF NO RETURN"; +"shredder.warning.direct" = "CleanMac bypasses Trash and does not create restore history. Once execution starts, it cannot be cancelled."; +"shredder.warning.ssd" = "SSD/APFS note: per-file overwrite cannot guarantee that old physical blocks, clones, or snapshots are unrecoverable. FileVault protects future data more reliably."; +"shredder.queue.title" = "TARGET QUEUE"; +"shredder.queue.detail" = "Only explicitly chosen, unchanged, single-link regular files can be armed."; +"shredder.add" = "Add Files"; +"shredder.execute" = "ARM & SHRED"; +"shredder.progress" = "%d / %d"; +"shredder.empty.title" = "No targets armed"; +"shredder.empty.message" = "Add one or more ordinary files. Folders, packages, links, and protected system paths are rejected."; +"shredder.picker.confirm" = "Review Files"; +"shredder.picker.message" = "Choose ordinary files for irreversible review. Nothing is deleted from this dialog."; +"shredder.file.bestEffort" = "APFS BEST EFFORT"; +"shredder.file.overwrite" = "OVERWRITE"; +"shredder.file.metadata" = "%@ · %@"; +"shredder.remove" = "Remove from queue"; +"shredder.confirm.phrase" = "DELETE FOREVER"; +"shredder.confirm.title" = "Authorize irreversible deletion"; +"shredder.confirm.summary" = "%d file(s), %@ total"; +"shredder.confirm.warning" = "Selected files will be overwritten once with random data, synced, truncated, and unlinked without Trash. CleanMac cannot restore them. Physical SSD/APFS recovery still cannot be guaranteed."; +"shredder.confirm.acknowledge" = "I understand this bypasses Trash, has no CleanMac restore path, and cannot guarantee physical SSD/APFS erasure."; +"shredder.confirm.type" = "Type %@ exactly to unlock the final action."; +"shredder.confirm.action" = "Shred Now"; +"shredder.status.added" = "Added %d reviewed file(s) to the target queue."; +"shredder.status.complete" = "Directly removed %d file(s), %@ total. No Trash or restore record was created."; +"shredder.error.file" = "%@: %@"; +"shredder.error.generic" = "The file could not be reviewed."; +"shredder.error.unavailable" = "The path is no longer available."; +"shredder.error.protected" = "Protected system paths are blocked."; +"shredder.error.package" = "Application and package contents are blocked."; +"shredder.error.symlink" = "Symbolic links are not accepted."; +"shredder.error.regularOnly" = "Only ordinary files are accepted; folders are blocked."; +"shredder.error.hardLink" = "Files with multiple hard links are blocked to protect the other names."; +"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."; + "onboarding.appTitle" = "Meet CleanMac"; "onboarding.skip" = "Skip introduction"; "onboarding.progress" = "Step %d of %d"; diff --git a/CleanMac/ru.lproj/Localizable.strings b/CleanMac/ru.lproj/Localizable.strings index 4b6df2e..aeadeda 100644 --- a/CleanMac/ru.lproj/Localizable.strings +++ b/CleanMac/ru.lproj/Localizable.strings @@ -6,6 +6,7 @@ "section.results" = "Результаты"; "section.diskAnalysis" = "Анализ диска"; "section.duplicates" = "Дубликаты"; +"section.shredder" = "Шредер"; "section.applications" = "Приложения"; "section.permissions" = "Доступы"; "section.settings" = "Настройки"; @@ -521,6 +522,50 @@ "duplicates.empty.results.title" = "Точных дубликатов не найдено"; "duplicates.empty.results.message" = "Копий для выбора нет. Крупные кандидаты могут быть доступны через медленный режим."; +"shredder.header.code" = "SECURE_ERASE // OFFLINE"; +"shredder.title" = "Умный шредер"; +"shredder.subtitle" = "Проверка обычных файлов, перезапись и прямое удаление без Корзины."; +"shredder.state.local" = "ЛОКАЛЬНО"; +"shredder.state.noTrash" = "БЕЗ КОРЗИНЫ"; +"shredder.state.failClosed" = "FAIL CLOSED"; +"shredder.queue.summary" = "Файлов: %d · %@"; +"shredder.warning.title" = "ТОЧКА НЕВОЗВРАТА"; +"shredder.warning.direct" = "CleanMac обходит Корзину и не создаёт историю восстановления. После запуска операцию нельзя отменить."; +"shredder.warning.ssd" = "Важно для SSD/APFS: перезапись файла не гарантирует уничтожение старых физических блоков, clones или snapshots. Для будущих данных надёжнее заранее использовать FileVault."; +"shredder.queue.title" = "ОЧЕРЕДЬ ЦЕЛЕЙ"; +"shredder.queue.detail" = "Можно активировать только явно выбранные, неизменённые обычные файлы с одной hard link."; +"shredder.add" = "Добавить файлы"; +"shredder.execute" = "АКТИВИРОВАТЬ"; +"shredder.progress" = "%d / %d"; +"shredder.empty.title" = "Цели не выбраны"; +"shredder.empty.message" = "Добавь один или несколько обычных файлов. Папки, packages, links и защищённые системные пути будут отклонены."; +"shredder.picker.confirm" = "Проверить файлы"; +"shredder.picker.message" = "Выбери обычные файлы для необратимой проверки. В этом окне ничего не удаляется."; +"shredder.file.bestEffort" = "APFS BEST EFFORT"; +"shredder.file.overwrite" = "ПЕРЕЗАПИСЬ"; +"shredder.file.metadata" = "%@ · %@"; +"shredder.remove" = "Убрать из очереди"; +"shredder.confirm.phrase" = "УДАЛИТЬ НАВСЕГДА"; +"shredder.confirm.title" = "Подтверждение необратимого удаления"; +"shredder.confirm.summary" = "Файлов: %d, общий объём: %@"; +"shredder.confirm.warning" = "Выбранные файлы будут один раз перезаписаны случайными данными, синхронизированы, обнулены и удалены напрямую без Корзины. CleanMac не сможет их восстановить. Гарантировать физическое стирание на SSD/APFS всё равно невозможно."; +"shredder.confirm.acknowledge" = "Я понимаю, что Корзина и восстановление CleanMac не используются, а физическое стирание SSD/APFS не гарантируется."; +"shredder.confirm.type" = "Введи %@ без изменений, чтобы разблокировать последнее действие."; +"shredder.confirm.action" = "Удалить напрямую"; +"shredder.status.added" = "В очередь добавлено проверенных файлов: %d."; +"shredder.status.complete" = "Удалено напрямую файлов: %d, общий объём: %@. Корзина и история восстановления не создавались."; +"shredder.error.file" = "%@: %@"; +"shredder.error.generic" = "Не удалось проверить файл."; +"shredder.error.unavailable" = "Путь больше недоступен."; +"shredder.error.protected" = "Защищённые системные пути заблокированы."; +"shredder.error.package" = "Содержимое приложений и packages заблокировано."; +"shredder.error.symlink" = "Символические ссылки не принимаются."; +"shredder.error.regularOnly" = "Принимаются только обычные файлы; папки заблокированы."; +"shredder.error.hardLink" = "Файлы с несколькими hard links заблокированы для защиты других имён."; +"shredder.error.changed" = "Файл изменился или был заменён после проверки."; +"shredder.error.busy" = "Файл занят или заблокирован другим процессом."; +"shredder.error.io" = "Перезапись или прямое удаление завершились ошибкой; файл не отмечен как удалённый."; + "onboarding.appTitle" = "Знакомство с CleanMac"; "onboarding.skip" = "Пропустить знакомство"; "onboarding.progress" = "Шаг %d из %d"; diff --git a/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift b/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift new file mode 100644 index 0000000..6cc3a51 --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift @@ -0,0 +1,270 @@ +import Darwin +import Foundation + +public struct SecureDeletionCandidate: Identifiable, Equatable, Sendable { + public let path: String + public let name: String + public let sizeBytes: Int64 + public let fileSystemName: String + + let deviceID: UInt64 + let inode: UInt64 + let modificationSeconds: Int64 + let modificationNanoseconds: Int64 + + public var id: String { path } + + public var isAPFS: Bool { + fileSystemName.localizedCaseInsensitiveContains("apfs") + } + + init(path: String, name: String, sizeBytes: Int64, fileSystemName: String, metadata: stat) { + self.path = path + self.name = name + self.sizeBytes = max(0, sizeBytes) + self.fileSystemName = fileSystemName + self.deviceID = UInt64(metadata.st_dev) + self.inode = UInt64(metadata.st_ino) + self.modificationSeconds = Int64(metadata.st_mtimespec.tv_sec) + self.modificationNanoseconds = Int64(metadata.st_mtimespec.tv_nsec) + } +} + +public enum SecureDeletionError: Error, Equatable, Sendable { + case pathUnavailable + case protectedPath + case packageContent + case symbolicLink + case notRegularFile + case multipleHardLinks + case fileChanged + case fileBusy + case openFailed(Int32) + case writeFailed(Int32) + case syncFailed(Int32) + case truncateFailed(Int32) + case removeFailed(Int32) +} + +public struct SecureFileShredder: Sendable { + private static let bufferSize = 1024 * 1024 + + private static let protectedRoots = [ + "/System", + "/Library", + "/Applications", + "/bin", + "/sbin", + "/usr", + "/etc", + "/var/db", + "/var/root", + "/var/vm", + "/private/etc", + "/private/var/db", + "/private/var/root", + "/private/var/vm", + "/opt" + ] + + private static let packageExtensions = [ + "app", "appex", "bundle", "framework", "plugin", "xpc", "pkg", "mpkg" + ] + + private let beforeUnlink: @Sendable (String) throws -> Void + + public init() { + self.beforeUnlink = { _ in } + } + + init(beforeUnlink: @escaping @Sendable (String) throws -> Void) { + self.beforeUnlink = beforeUnlink + } + + public func inspect(url: URL) throws -> SecureDeletionCandidate { + let standardizedURL = url.standardizedFileURL + let path = standardizedURL.path + + guard !path.isEmpty else { + throw SecureDeletionError.pathUnavailable + } + + var metadata = stat() + guard path.withCString({ Darwin.lstat($0, &metadata) }) == 0 else { + throw SecureDeletionError.pathUnavailable + } + + let fileType = metadata.st_mode & mode_t(S_IFMT) + guard fileType != mode_t(S_IFLNK) else { + throw SecureDeletionError.symbolicLink + } + guard fileType == mode_t(S_IFREG) else { + throw SecureDeletionError.notRegularFile + } + guard metadata.st_nlink == 1 else { + throw SecureDeletionError.multipleHardLinks + } + + let canonicalURL = standardizedURL.resolvingSymlinksInPath().standardizedFileURL + guard !Self.isProtected(path: path), !Self.isProtected(path: canonicalURL.path) else { + throw SecureDeletionError.protectedPath + } + guard !Self.isInsidePackage(url: standardizedURL), !Self.isInsidePackage(url: canonicalURL) else { + throw SecureDeletionError.packageContent + } + + let volumeValues = try? standardizedURL.resourceValues(forKeys: [.volumeLocalizedFormatDescriptionKey]) + let fileSystemName = volumeValues?.volumeLocalizedFormatDescription ?? "Unknown" + + return SecureDeletionCandidate( + path: path, + name: standardizedURL.lastPathComponent, + sizeBytes: Int64(metadata.st_size), + fileSystemName: fileSystemName, + metadata: metadata + ) + } + + @discardableResult + public func shred(_ candidate: SecureDeletionCandidate) 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 { + throw SecureDeletionError.protectedPath + } + guard !Self.isInsidePackage(url: candidateURL), !Self.isInsidePackage(url: canonicalURL) else { + throw SecureDeletionError.packageContent + } + + let descriptor = candidate.path.withCString { + Darwin.open($0, O_WRONLY | O_NOFOLLOW | O_CLOEXEC) + } + guard descriptor >= 0 else { + throw SecureDeletionError.openFailed(errno) + } + defer { Darwin.close(descriptor) } + + guard Darwin.lockf(descriptor, F_TLOCK, 0) == 0 else { + throw SecureDeletionError.fileBusy + } + defer { Darwin.lockf(descriptor, F_ULOCK, 0) } + + var openedMetadata = stat() + guard Darwin.fstat(descriptor, &openedMetadata) == 0 else { + throw SecureDeletionError.openFailed(errno) + } + try validate(candidate: candidate, metadata: openedMetadata, includeMutableMetadata: true) + + if candidate.sizeBytes > 0 { + try overwrite(descriptor: descriptor, byteCount: candidate.sizeBytes) + guard Darwin.fsync(descriptor) == 0 else { + throw SecureDeletionError.syncFailed(errno) + } + } + + try beforeUnlink(candidate.path) + + guard Darwin.ftruncate(descriptor, 0) == 0 else { + throw SecureDeletionError.truncateFailed(errno) + } + guard Darwin.fsync(descriptor) == 0 else { + throw SecureDeletionError.syncFailed(errno) + } + + var pathMetadata = stat() + guard candidate.path.withCString({ Darwin.lstat($0, &pathMetadata) }) == 0 else { + throw SecureDeletionError.fileChanged + } + try validate(candidate: candidate, metadata: pathMetadata, includeMutableMetadata: false) + + guard candidate.path.withCString({ Darwin.unlink($0) }) == 0 else { + throw SecureDeletionError.removeFailed(errno) + } + + return candidate.sizeBytes + } + + private func overwrite(descriptor: Int32, byteCount: Int64) 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) + + while remaining > 0 { + let chunkSize = min(Int64(buffer.count), remaining) + buffer.withUnsafeMutableBytes { bytes in + if let baseAddress = bytes.baseAddress { + arc4random_buf(baseAddress, Int(chunkSize)) + } + } + + var written = 0 + while written < Int(chunkSize) { + let result = buffer.withUnsafeBytes { bytes in + Darwin.write( + descriptor, + bytes.baseAddress!.advanced(by: written), + Int(chunkSize) - written + ) + } + + if result < 0 { + if errno == EINTR { + continue + } + throw SecureDeletionError.writeFailed(errno) + } + guard result > 0 else { + throw SecureDeletionError.writeFailed(EIO) + } + written += result + } + + remaining -= chunkSize + } + } + + private func validate( + candidate: SecureDeletionCandidate, + metadata: stat, + includeMutableMetadata: Bool + ) throws { + let fileType = metadata.st_mode & mode_t(S_IFMT) + guard fileType != mode_t(S_IFLNK) else { + throw SecureDeletionError.symbolicLink + } + guard fileType == mode_t(S_IFREG) else { + throw SecureDeletionError.notRegularFile + } + guard metadata.st_nlink == 1 else { + throw SecureDeletionError.multipleHardLinks + } + guard UInt64(metadata.st_dev) == candidate.deviceID, + UInt64(metadata.st_ino) == candidate.inode else { + throw SecureDeletionError.fileChanged + } + + if includeMutableMetadata { + guard Int64(metadata.st_size) == candidate.sizeBytes, + Int64(metadata.st_mtimespec.tv_sec) == candidate.modificationSeconds, + Int64(metadata.st_mtimespec.tv_nsec) == candidate.modificationNanoseconds else { + throw SecureDeletionError.fileChanged + } + } + } + + private static func isProtected(path: String) -> Bool { + protectedRoots.contains { root in + path == root || path.hasPrefix(root + "/") + } + } + + private static func isInsidePackage(url: URL) -> Bool { + url.pathComponents.contains { component in + let pathExtension = URL(fileURLWithPath: component).pathExtension.lowercased() + return packageExtensions.contains(pathExtension) + } + } +} diff --git a/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift new file mode 100644 index 0000000..25f0867 --- /dev/null +++ b/CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift @@ -0,0 +1,121 @@ +import Darwin +import Foundation +import XCTest +@testable import CleanMacCore + +final class SecureFileShredderTests: XCTestCase { + func testShredOverwritesBeforeDirectRemoval() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let file = root.appending(path: "secret.bin") + let original = Data(repeating: 0x41, count: 2 * 1024 * 1024) + try original.write(to: file) + + let candidate = try SecureFileShredder().inspect(url: file) + let probe = ShredOverwriteProbe(original: original) + let shredder = SecureFileShredder(beforeUnlink: probe.inspect) + + let removedBytes = try shredder.shred(candidate) + + XCTAssertEqual(removedBytes, Int64(original.count)) + XCTAssertTrue(probe.didInspect) + XCTAssertTrue(probe.wasFullyOverwritten) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + } + + func testInspectionRejectsDirectoriesSymlinksHardLinksAndPackages() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertThrowsError(try SecureFileShredder().inspect(url: root)) { error in + XCTAssertEqual(error as? SecureDeletionError, .notRegularFile) + } + + let regularFile = root.appending(path: "regular.txt") + try Data("value".utf8).write(to: regularFile) + + let symlink = root.appending(path: "link.txt") + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: regularFile) + XCTAssertThrowsError(try SecureFileShredder().inspect(url: symlink)) { error in + XCTAssertEqual(error as? SecureDeletionError, .symbolicLink) + } + + let hardLink = root.appending(path: "hard-link.txt") + try FileManager.default.linkItem(at: regularFile, to: hardLink) + XCTAssertThrowsError(try SecureFileShredder().inspect(url: regularFile)) { error in + XCTAssertEqual(error as? SecureDeletionError, .multipleHardLinks) + } + + let appContents = root + .appending(path: "Unsafe.app", directoryHint: .isDirectory) + .appending(path: "Contents", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: appContents, withIntermediateDirectories: true) + let packagedFile = appContents.appending(path: "payload.bin") + try Data("payload".utf8).write(to: packagedFile) + XCTAssertThrowsError(try SecureFileShredder().inspect(url: packagedFile)) { error in + XCTAssertEqual(error as? SecureDeletionError, .packageContent) + } + } + + func testShredRejectsAFileChangedAfterReview() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let file = root.appending(path: "changing.bin") + try Data(repeating: 0x22, count: 128).write(to: file) + let candidate = try SecureFileShredder().inspect(url: file) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data([0x33])) + try handle.close() + + XCTAssertThrowsError(try SecureFileShredder().shred(candidate)) { error in + XCTAssertEqual(error as? SecureDeletionError, .fileChanged) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path)) + XCTAssertEqual(try Data(contentsOf: file).count, 129) + } + + func testInspectionRejectsCanonicalSystemAlias() throws { + let hosts = URL(fileURLWithPath: "/etc/hosts") + XCTAssertThrowsError(try SecureFileShredder().inspect(url: hosts)) { error in + XCTAssertEqual(error as? SecureDeletionError, .protectedPath) + } + } + + private func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appending(path: "CleanMac-ShredderTests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } +} + +private final class ShredOverwriteProbe: @unchecked Sendable { + private let lock = NSLock() + private let original: Data + private var inspected = false + private var fullyOverwritten = false + + init(original: Data) { + self.original = original + } + + var didInspect: Bool { + lock.withLock { inspected } + } + + var wasFullyOverwritten: Bool { + lock.withLock { fullyOverwritten } + } + + func inspect(path: String) throws { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + lock.withLock { + inspected = true + fullyOverwritten = data.count == original.count && data != original + } + } +} diff --git a/README.md b/README.md index 1201447..aa1c8ad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Safe, local-first cleanup for macOS with review-first scanning,
- Safe Mode, and Trash-only removal. + Safe Mode, Trash-based cleanup, 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 only confirmed items to Trash. 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. 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. @@ -37,7 +37,8 @@ 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-only cleanup.** Accepted paths are validated again before execution; permanent deletion is not used. +- **Trash-based cleanup.** Normal cleanup, duplicate removal, and application 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. - **Menu bar and scheduled scans.** Disk status, the latest scan summary, safe scan scheduling, and local notifications while CleanMac is running. @@ -50,9 +51,10 @@ 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. Accepted files are moved to Trash instead of being permanently deleted. +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. CleanMac does not escalate privileges or install a system helper. +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. ## Installation @@ -117,7 +119,7 @@ docs/ Documentation and screenshots ## Contributing 1. Create a focused branch from `main`. -2. Preserve the safety flow: scan → review → confirm → Trash. +2. Preserve the standard safety flow: scan → review → confirm → Trash. Keep irreversible actions isolated inside Smart Shredder. 3. Add tests for changes to scanning, path validation, or removal behavior. 4. Before opening a pull request, run `swift test --package-path CleanMacCore` and `./script/build_and_run.sh --verify`. diff --git a/contract.md b/contract.md index 083645d..89fe25c 100644 --- a/contract.md +++ b/contract.md @@ -2,64 +2,63 @@ ## Task -- ID: TASK-044 -- Title: Low disk space warning +- ID: TASK-046 +- Title: Smart irreversible file shredder - Mode: continue ## Planner Notes -- Why this task now: the menu-bar dashboard already samples free disk capacity and can surface an actionable warning before storage is exhausted. -- Expected value: a clear under-10% warning, an immediate path to Disk Analysis, and a restrained local notification. -- Main risk: repeated one-second samples can spam notifications, and requesting notification permission automatically would be intrusive. -- Safety choice: keep the menu warning live, notify at most once per 24 hours only when macOS permission is already granted, and never start scanning automatically. +- Why this task now: the user explicitly requested a separate hacker-style tool for deleting selected files without the Trash or CleanMac restore path. +- Expected value: a deliberately isolated destructive workflow with review, clear limitations, strong confirmation, and fail-closed path validation. +- Main risk: per-file overwrite cannot guarantee physical media erasure on SSD/APFS because copy-on-write, clones, snapshots, asynchronous TRIM, and flash wear leveling may preserve older blocks. +- Safety choice: describe the operation as best-effort irreversible direct deletion; accept only explicitly selected regular files; reject directories, symlinks, hard links, packages, protected system roots, and files changed after review; use descriptor-based overwrite plus identity revalidation and direct unlink. ## Builder Scope - Allowed files: - - `CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift`; - - `CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift`; - - `CleanMac/Support/StatusSystemMetrics.swift`; - - `CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift`; - - `CleanMac/Support/CleanMacNotificationService.swift`; - - `CleanMac/Support/CleanMacPreferences.swift`; - - `CleanMac/Support/MainWindowController.swift`; - - `CleanMac/Views/StatusMenuView.swift`; + - `CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift`; + - focused `CleanMacCore` tests; + - `CleanMac/Models/CleanMacModels.swift`; + - `CleanMac/Support/ShredderWorkspaceService.swift`; + - `CleanMac/Views/ShredderView.swift`; - `CleanMac/Views/MainWindowView.swift`; - - `CleanMac/CleanMacApp.swift`; - RU/EN localization; + - `README.md`; - Loop documentation files. - Allowed commands: - - source inspection and Debug build/launch; - - focused policy fixtures and full core tests; - - read-only menu navigation review without sending a real notification; + - source inspection; + - direct deletion only inside disposable test fixture directories; - `swift test --package-path CleanMacCore`; - - standard Git/PR/CI checks and merge after green status. + - Debug build and `./script/build_and_run.sh --verify`; + - localization and Git diff checks; + - non-destructive UI review up to, but not accepting, the final destructive confirmation. - Out of scope: - - automatic scanning/cleanup, notification permission prompts, Settings redesign, release/version/package changes, dependencies, or architecture changes. + - deleting any real user file during verification, directories, application bundles, privileged/system files, background shredding, scheduled shredding, dependencies, release/version/package changes, signing, or publication. - Dependencies allowed: none -- Destructive actions allowed: none +- Destructive actions allowed: disposable test fixtures only ## Evaluator Checklist - Done criteria: - - Low space means valid total capacity with free fraction strictly below 10%. - - Notification eligibility is limited to once per 24 hours and persisted only after successful delivery. - - The monitor checks at launch and every 30 minutes without requesting permission. - - The menu warning recommends scanning and provides a direct Disk Analysis button. - - Navigation reveals the existing main window or opens one, then selects Disk Analysis without starting analysis. + - Shredder is a separate sidebar destination and does not reuse normal cleanup, Trash, restore, or scheduled scan flows. + - Review accepts only explicitly selected regular files and records device/inode/size/mtime identity. + - Execution opens without following symlinks, verifies the same single-link regular file, overwrites through the file descriptor, syncs, truncates, revalidates identity, and unlinks directly. + - Protected roots, directories, symlinks, hard links, packages, and changed/replaced files fail closed. + - Final action requires both an acknowledgement and an exact typed phrase; no cancellation is offered after execution starts. + - UI clearly states that SSD/APFS physical recovery cannot be guaranteed and recommends FileVault for future protection. + - Neo-glow styling is concentrated on armed/danger states and supports both app appearances. - Required verification: - - focused threshold/boundary/cooldown tests; + - focused shredder tests and full `swift test --package-path CleanMacCore`; + - localization lint/key parity; - Debug build and signed launch verification; - - live menu-to-Disk-Analysis navigation without triggering a scan or notification; - - `swift test --package-path CleanMacCore`; - - `git diff --check`; - - green GitHub PR checks. + - live RU/EN layout and confirmation review without accepting deletion; + - `git diff --check`. - Manual checks: - - Confirm normal disk space keeps the warning hidden. - - Do not change notification permission, start scanning, or move any user file during verification. + - Never select or delete a real user file during automated verification. + - Confirm normal cleanup and restore behavior remain unchanged. ## Result - Status: complete -- Verification result: focused boundary/cooldown tests and all 43 core tests passed; localization lint/key parity, Debug build, ad-hoc signature/launch, normal-capacity hidden state, one-shot Disk Analysis routing, diff checks, and GitHub PR CI passed. -- Notes: the warning uses the same available-capacity value shown in the menu-bar disk card; it never requests notification permission or starts a scan automatically. APFS purgeable-space behavior remains represented by macOS capacity APIs. +- Verification result: focused shredder tests 4/4; full `CleanMacCore` suite 47/47; EN/RU localization lint and 583-key parity; Debug build, ad-hoc signature, and launch verification; live EN/light and RU/dark UI review; exact-phrase gating review; `git diff --check`. +- Notes: The production path was not exercised against a real user file. Only disposable SwiftPM fixtures were shredded; the UI fixture was preserved. Apple documents that secure erase options are unavailable for SSDs, so the UI explicitly describes this as best-effort direct deletion rather than guaranteed physical-media erasure. diff --git a/progress.md b/progress.md index a690a9b..4b9b988 100644 --- a/progress.md +++ b/progress.md @@ -410,3 +410,23 @@ Append-only history. Do not erase previous entries. - Next step: Publish a feature release only when explicitly requested. - Bottleneck: system notification delivery remains dependent on macOS permission; the known CoreSimulator version warning remains unrelated and non-blocking for macOS builds. - Handoff: The current Debug app uses the normal live disk capacity, so the warning remains naturally hidden. No scan, cleanup, application removal, release, or user-file mutation occurred. + +## 2026-07-12 - TASK-045 - Reduce scan thermal load + +- What changed: Replaced the two continuous 30 FPS `TimelineView` progress animations with event-driven progress visuals and system `ProgressView`; bounded cleanup, Disk Analysis, and Duplicate Finder progress streams to the newest pending event; and moved the long read-only Disk Analysis and duplicate workers from user-initiated to utility priority. +- Files touched: `CleanMac/Views/DiskAnalysisProgressIndicator.swift`, `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DuplicateFinderView.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ScanActivityView.swift`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `verification.md`. +- Checks run: repeated live `ps` CPU sampling; 8-second before/after `/usr/bin/sample` captures during Home Disk Analysis; `swift test --package-path CleanMacCore` (43/43); localization plist lint; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; `git diff --check`; source check confirming no scan-progress `TimelineView` remains. +- Result: Passed. The reproduced baseline stayed at 111–124% CleanMac CPU. The optimized build measured 28.6% on startup, 1.5% on the next sample, and 0% on the following eight samples while the analysis remained active. The baseline sample spent 1,469 samples in repeated window constraint updates; the optimized sample removed that cycle and showed the scan worker on `com.apple.root.utility-qos.cooperative`. +- Next step: Repeat the same benchmark on an uncached whole-disk scan after normal use if a longer thermal comparison is desired; no additional code change is currently required. +- Bottleneck: none. The known stale CoreSimulator warning remains non-blocking for macOS builds. +- Handoff: Only read-only Home Disk Analysis was started and cancelled during verification. No cleanup, duplicate removal, application removal, permission, setting, release, or user file was changed. + +## 2026-07-12 - TASK-046 - Smart irreversible file shredder + +- What changed: Added a separate Smart Shredder sidebar destination with adaptive neo-glow styling, explicit multi-file selection, file-system/APFS status, fail-closed review, and a two-part irreversible confirmation. The core accepts only unchanged single-link regular files, pins and locks the descriptor without following symlinks, performs one random overwrite pass with sync, truncates and syncs, revalidates device/inode identity, and directly unlinks without Trash, CleanMac history, restore, scheduling, or cancellation after execution begins. Protected system roots and their `/etc`/`/var` aliases, package contents, directories, symlinks, hard links, and changed/replaced files are rejected. The UI explicitly documents the SSD/APFS limitation and recommends FileVault. +- Files touched: `CleanMacCore/Sources/CleanMacCore/SecureFileShredder.swift`, `CleanMacCore/Tests/CleanMacCoreTests/SecureFileShredderTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Support/ShredderWorkspaceService.swift`, `CleanMac/Views/ShredderView.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `README.md`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: focused `SecureFileShredderTests` (4/4); full `swift test --package-path CleanMacCore` (47/47); localization plist lint and EN/RU parity (583 keys); Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live EN/light empty-state and RU/dark queued/armed confirmation review; confirmation button disabled/enabled gating check; `git diff --check`; disposable UI fixture preservation check. +- Result: Passed. Destructive behavior was exercised only against disposable SwiftPM fixtures. The UI confirmation was never accepted, and the selected UI fixture remains preserved outside the repository. Standard cleanup and restore paths remain unchanged. +- Next step: Commit and push the feature only when explicitly requested; package or publish a release separately when approved. +- Bottleneck: physical-media erasure cannot be guaranteed on SSD/APFS; the known stale CoreSimulator warning remains non-blocking for macOS builds. +- Handoff: The current Debug app builds, signs, and launches. CleanMac preferences were restored to English/light after RU/dark review. No real user file, setting, permission, package, release, or remote repository state was changed. diff --git a/project-analysis.md b/project-analysis.md index 8398f8a..09a1fa4 100644 --- a/project-analysis.md +++ b/project-analysis.md @@ -45,7 +45,9 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - `CleanMacCore` has a read-only scanner for user caches, logs, temporary files, Trash, Downloads review, Xcode Derived Data, browser caches, Node/npm/Yarn/pnpm caches, SwiftPM cache, and downloaded installers. - Advanced developer cleanup uses exact allowlists for Homebrew, pip, Cargo registry cache/source, Gradle, Cursor/VS Code caches, Codex/Claude cache/temp folders, Xcode DeviceSupport, Previews, old user Simulator data, and individual Xcode Archives. IDE settings/extensions and AI projects/sessions/history/memory are excluded; Simulator and Archives require review, and Archives are never selected by default. - Disk Analysis is a separate read-only workspace with whole-disk (`/` with no path exclusions), Home, Downloads, and custom-folder sources. One cancellable scan powers a bounded multi-ring folder map plus a non-selected large-file list with 50 MB, 100 MB, 500 MB, and 1 GB filters, size/date/type sorting, and Finder/Open actions. Its data never enters cleanup reports, junk totals, history, or scheduled scans. +- 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. - 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. @@ -78,7 +80,8 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - 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. -- Cleanup is intentionally Trash-based; permanent deletion is still out of scope. +- 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. +- 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. - 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. diff --git a/roadmap.md b/roadmap.md index c2c88fa..8e9f21c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,5 +1,33 @@ # Roadmap +- [x] ID: TASK-046 + Title: Smart irreversible file shredder + Goal: Add an isolated hacker-style workflow for explicit best-effort overwrite and direct deletion of reviewed files. + What to do: Implement fail-closed descriptor-based shredding, disposable-fixture tests, multi-file review, exact typed confirmation, SSD/APFS limitation copy, and a dual-theme neo-glow sidebar destination. + Files: shredder core/tests, Shredder view/workspace service, navigation/localization, Loop docs + Definition of done: only unchanged single-link regular files can be processed; folders/symlinks/hard links/packages/system roots are rejected; no Trash/restore path exists; confirmation is explicit; UI never promises guaranteed SSD physical erasure. + Verification: focused and full SwiftPM tests; localization parity; Debug build/launch; non-destructive RU/EN UI review; `git diff --check` + Priority: high + Impact: high + Risk: high + Effort: medium + Confidence: high + Score: high impact / high risk / medium + +- [x] ID: TASK-045 + Title: Reduce scan thermal load + Goal: Prevent Disk Analysis and scan progress UI from sustaining excessive CPU and heating the Mac. + What to do: Replace frame-driven progress visuals with event-driven/system animation, bound progress streams to the newest event, and run long read-only analysis workers at utility priority. + Files: scan progress views, Disk Analysis/Duplicate Finder/MainWindow progress coordination, Loop docs + Definition of done: progress remains responsive and cancellable; stale progress cannot queue unboundedly; no continuous TimelineView invalidates the main layout; the same Home benchmark is materially below the 111–124% baseline. + Verification: full SwiftPM tests; Debug build/launch; before/after CPU and stack samples; `git diff --check` + Priority: high + Impact: high + Risk: low + Effort: small + Confidence: high + Score: high impact / low risk / small + - [x] ID: TASK-044 Title: Low disk space warning Goal: Warn when available disk space falls below 10% without spamming or starting cleanup automatically. diff --git a/trace.md b/trace.md index 794b22d..87e59e4 100644 --- a/trace.md +++ b/trace.md @@ -134,3 +134,17 @@ Append-only trace of failures, restarts, and judgment divergences. - Cause: `onAppear` applied the external selection before SwiftUI restored the existing `@SceneStorage` sidebar value. - Fix: consume the requested section from an ID-driven task after one main-actor yield, then clear the one-shot preference. - Status: resolved; the second clean launch opened directly on the Russian Disk Analysis screen and did not start a scan. + +## 2026-07-12 - TASK-046 - System alias escaped the first protected-root check + +- Symptom: the first full 47-test run let `/etc/hosts` pass inspection even though `/private/etc` was protected. +- Cause: Foundation path resolution did not canonicalize the existing `/etc` parent alias in this validation path. +- Fix: explicitly protect `/etc`, `/var/db`, `/var/root`, and `/var/vm` alongside their `/private` targets, then rerun the focused and full suites. +- Status: resolved; the alias regression and all four shredder tests pass, and all 47 core tests are green. + +## 2026-07-12 - TASK-046 - Repeated generated app Finder metadata + +- Symptom: the first final launch verification built successfully but ad-hoc signing rejected `com.apple.FinderInfo` on the generated Debug app. +- Cause: the known local Finder/File Provider metadata behavior recurred on the build product. +- Fix: cleared extended attributes only from `build/XcodeData/Build/Products/Debug/CleanMac.app` and repeated the standard verification command. +- Status: resolved; the Debug app is valid on disk, satisfies its designated requirement, and launches successfully. diff --git a/verification.md b/verification.md index 4b0e92d..93e3e1b 100644 --- a/verification.md +++ b/verification.md @@ -55,8 +55,10 @@ | Developer cleanup | Exact-root temporary fixtures plus read-only Scan review | Developer package, IDE, AI-tool, Xcode, Simulator, or Archive categories change | Only listed cache/temp roots produce results; settings/extensions/projects/sessions/history/memory stay excluded; recent Simulator data is skipped; Archives remain review-only and non-default | Run focused `testDeveloper*`, `testScannerFindsDeveloper*`, and `testXcodeDeveloperStorage*` tests; inspect Scan without starting cleanup | | Persistent cleanup history | Temporary JSON/history fixtures, multi-window snapshots, and injected restore move handlers | History storage, migration, or restore validation changes | Round-trip/status/cap/merge tests pass; corrupt, oversized, outside-Trash, symlink, and outside-allowlist records never reach the move handler; default restore walks path components without following symlinks and performs descriptor-relative exclusive rename | Run focused `testCleanupHistory*`, `testPersistedCleanupHistory*`, and `testSecureRestoreMove*` SwiftPM tests; inspect Results without triggering cleanup | | Application removal | Temporary fake `.app` fixtures with an injected Trash handler plus read-only UI review | Application scanner, removal policy, multi-selection, or Applications UI changes | App target is moved first; failed app move leaves leftovers untouched; outside/forged paths are rejected; multiple checkboxes and per-app leftovers stay isolated; no real app is removed | Run the focused `testApplication*` SwiftPM tests and inspect the batch confirmation without accepting it | -| Disk analysis | Temporary folder fixtures plus a read-only live source scan | Disk analyzer, map, large-file review, folder source, progress UI, hover behavior, or Finder/Open actions | Tree totals and large-file thresholds are correct; symlink traversal is skipped; cancellation works; root branches stay visible after the deep-node cap; no initial file selection or cleanup path exists; whole-disk mode starts at `/` with no path exclusions; scan progress uses the custom animation and a hovered sector enlarges with a localized GB tooltip | Run `DiskAnalyzerTests`, inspect Home/Downloads first, then use a cancellable `/` scan and confirm protected paths appear as issues rather than privilege escalation | +| 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 | | 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` | | 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 |