diff --git a/CleanMac.xcodeproj/project.pbxproj b/CleanMac.xcodeproj/project.pbxproj index f6e84e1..e14afc8 100644 --- a/CleanMac.xcodeproj/project.pbxproj +++ b/CleanMac.xcodeproj/project.pbxproj @@ -258,6 +258,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -266,6 +267,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = CleanMac; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "CleanMac uses Automation to ask Finder to reveal cleanup items you choose."; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -291,6 +293,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CleanMac/CleanMac.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -299,6 +302,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = CleanMac; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "CleanMac uses Automation to ask Finder to reveal cleanup items you choose."; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/CleanMac/CleanMac.entitlements b/CleanMac/CleanMac.entitlements new file mode 100644 index 0000000..49ad0bb --- /dev/null +++ b/CleanMac/CleanMac.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.automation.apple-events + + + diff --git a/CleanMac/Models/CleanMacModels.swift b/CleanMac/Models/CleanMacModels.swift index 9e7740c..1ea8a74 100644 --- a/CleanMac/Models/CleanMacModels.swift +++ b/CleanMac/Models/CleanMacModels.swift @@ -5,6 +5,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case dashboard case scan case results + case applications case permissions case settings @@ -15,6 +16,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .dashboard: L.t("section.dashboard") case .scan: L.t("section.scan") case .results: L.t("section.results") + case .applications: L.t("section.applications") case .permissions: L.t("section.permissions") case .settings: L.t("section.settings") } @@ -25,6 +27,7 @@ enum CleanMacSection: String, CaseIterable, Identifiable { case .dashboard: "gauge.with.dots.needle.67percent" case .scan: "magnifyingglass" case .results: "checklist" + case .applications: "app.badge.checkmark" case .permissions: "lock.shield" case .settings: "gearshape" } @@ -222,7 +225,10 @@ enum PermissionState { case limited case unknown case recommended - case later + case notRequested + case denied + case unavailable + case checking var title: String { switch self { @@ -230,7 +236,10 @@ enum PermissionState { case .limited: L.t("permission.state.limited") case .unknown: L.t("permission.state.unknown") case .recommended: L.t("permission.state.recommended") - case .later: L.t("permission.state.later") + case .notRequested: L.t("permission.state.notRequested") + case .denied: L.t("permission.state.denied") + case .unavailable: L.t("permission.state.unavailable") + case .checking: L.t("permission.state.checking") } } } @@ -365,7 +374,10 @@ enum CleanMacCatalog { ] } - static func permissions(fullDiskAccess: FullDiskAccessCheckResult) -> [PermissionItem] { + static func permissions( + fullDiskAccess: FullDiskAccessCheckResult, + finderAutomationPermission: FinderAutomationPermission? + ) -> [PermissionItem] { [ PermissionItem( id: "files", @@ -384,9 +396,9 @@ enum CleanMacCatalog { PermissionItem( id: "automation", title: L.t("permission.automation.title"), - detail: L.t("permission.automation.detail"), + detail: automationDetail(for: finderAutomationPermission), systemImage: "wand.and.stars", - state: .later + state: permissionState(for: finderAutomationPermission) ) ] } @@ -399,6 +411,23 @@ enum CleanMacCatalog { } } + private static func permissionState( + for automationPermission: FinderAutomationPermission? + ) -> PermissionState { + switch automationPermission { + case nil: + .checking + case .granted: + .granted + case .notDetermined: + .notRequested + case .denied: + .denied + case .targetNotRunning, .unavailable: + .unavailable + } + } + private static func fullDiskDetail(for result: FullDiskAccessCheckResult) -> String { switch result.state { case .granted: @@ -410,6 +439,25 @@ enum CleanMacCatalog { } } + private static func automationDetail( + for automationPermission: FinderAutomationPermission? + ) -> String { + switch automationPermission { + case nil: + L.t("permission.automation.detail.checking") + case .granted: + L.t("permission.automation.detail.granted") + case .notDetermined: + L.t("permission.automation.detail.notRequested") + case .denied: + L.t("permission.automation.detail.denied") + case .targetNotRunning: + L.t("permission.automation.detail.finderUnavailable") + case .unavailable: + L.t("permission.automation.detail.unavailable") + } + } + static func area(for category: CleanupCategory) -> CleanupArea { cleanupAreas.first { $0.category == category } ?? cleanupAreas[0] } diff --git a/CleanMac/PrivacyInfo.xcprivacy b/CleanMac/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..e85a6f6 --- /dev/null +++ b/CleanMac/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + 85F4.1 + + + + + diff --git a/CleanMac/Support/CleanMacAutomationService.swift b/CleanMac/Support/CleanMacAutomationService.swift new file mode 100644 index 0000000..78475a9 --- /dev/null +++ b/CleanMac/Support/CleanMacAutomationService.swift @@ -0,0 +1,102 @@ +import CoreServices +import Foundation + +nonisolated enum FinderAutomationPermission: Equatable, Sendable { + case granted + case notDetermined + case denied + case targetNotRunning + case unavailable +} + +enum CleanMacAutomationService { + nonisolated private static let finderBundleIdentifier = "com.apple.finder" + + static func checkFinderPermission() async -> FinderAutomationPermission { + await Task.detached(priority: .utility) { + determineFinderPermission(askUserIfNeeded: false) + }.value + } + + static func requestFinderPermission() async -> FinderAutomationPermission { + await Task.detached(priority: .userInitiated) { + determineFinderPermission(askUserIfNeeded: true) + }.value + } + + static func revealInFinder(_ url: URL) async -> Bool { + await Task.detached(priority: .userInitiated) { + guard determineFinderPermission(askUserIfNeeded: false) == .granted else { + return false + } + + return sendFinderEvent( + eventID: kAEMakeObjectsVisible, + directObject: NSAppleEventDescriptor(fileURL: url) + ) && sendFinderEvent(eventID: kAEActivate) + }.value + } + + nonisolated private static func determineFinderPermission( + askUserIfNeeded: Bool + ) -> FinderAutomationPermission { + let target = NSAppleEventDescriptor(bundleIdentifier: finderBundleIdentifier) + guard let targetDescriptor = target.aeDesc else { + return .unavailable + } + + let status = AEDeterminePermissionToAutomateTarget( + targetDescriptor, + typeWildCard, + typeWildCard, + askUserIfNeeded + ) + + switch status { + case noErr: + return .granted + case OSStatus(errAEEventWouldRequireUserConsent): + return .notDetermined + case OSStatus(errAEEventNotPermitted): + return .denied + case OSStatus(procNotFound): + return .targetNotRunning + case OSStatus(errAETargetAddressNotPermitted): + return .unavailable + default: + return .unavailable + } + } + + nonisolated private static func sendFinderEvent( + eventID: AEEventID, + directObject: NSAppleEventDescriptor? = nil + ) -> Bool { + let target = NSAppleEventDescriptor(bundleIdentifier: finderBundleIdentifier) + let event = NSAppleEventDescriptor( + eventClass: kAEMiscStandards, + eventID: eventID, + targetDescriptor: target, + returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID) + ) + + if let directObject { + event.setParam(directObject, forKeyword: keyDirectObject) + } + + let noPermissionPrompt = NSAppleEventDescriptor.SendOptions( + rawValue: UInt(kAEDoNotPromptForUserConsent) + ) + + do { + _ = try event.sendEvent( + options: [.waitForReply, .neverInteract, noPermissionPrompt], + timeout: 5 + ) + return true + } catch { + return false + } + } +} diff --git a/CleanMac/Views/ApplicationsView.swift b/CleanMac/Views/ApplicationsView.swift new file mode 100644 index 0000000..b6a08ba --- /dev/null +++ b/CleanMac/Views/ApplicationsView.swift @@ -0,0 +1,556 @@ +import AppKit +import CleanMacCore +import SwiftUI + +struct ApplicationsView: View { + @State private var applications: [InstalledApplication] = [] + @State private var selectedApplicationID: String? + @State private var selectedApplicationIDs = Set() + @State private var selectedLeftoverIDsByApplication: [String: Set] = [:] + @State private var searchText = "" + @State private var scanIssueCount = 0 + @State private var isScanning = false + @State private var isRemoving = false + @State private var isShowingConfirmation = false + @State private var statusMessage: String? + @State private var problemMessage: String? + + private var selectedApplication: InstalledApplication? { + applications.first { $0.id == selectedApplicationID } + } + + private var filteredApplications: [InstalledApplication] { + guard !searchText.isEmpty else { + return applications + } + return applications.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + || $0.bundleIdentifier.localizedCaseInsensitiveContains(searchText) + } + } + + private var selectedApplications: [InstalledApplication] { + applications.filter { selectedApplicationIDs.contains($0.id) } + } + + private var selectedLeftoverCount: Int { + selectedApplications.reduce(0) { count, application in + count + application.leftovers.filter { + selectedLeftoverIDsByApplication[application.id, default: []].contains($0.id) + }.count + } + } + + private var selectedRemovalSize: Int64 { + selectedApplications.reduce(0) { total, application in + let selectedLeftoverIDs = selectedLeftoverIDsByApplication[application.id] ?? [] + let leftoverSize = application.leftovers + .filter { selectedLeftoverIDs.contains($0.id) } + .reduce(0) { $0 + $1.sizeBytes } + return total + application.sizeBytes + leftoverSize + } + } + + private var removalButtonTitle: String { + selectedApplications.count == 1 + ? L.t("applications.remove.button") + : L.f("applications.remove.multiple", selectedApplications.count) + } + + var body: some View { + PageContainer { + VStack(alignment: .leading, spacing: 18) { + PageHeader( + title: L.t("applications.title"), + subtitle: L.t("applications.subtitle"), + systemImage: "app.badge.checkmark" + ) + + StatusBanner( + title: L.t("applications.safety.title"), + message: L.t("applications.safety.message"), + systemImage: "checkmark.shield", + tint: .blue + ) + + if let statusMessage { + StatusBanner( + title: L.t("applications.status.title"), + message: statusMessage, + systemImage: "checkmark.circle", + tint: .green + ) + } + + if let problemMessage { + StatusBanner( + title: L.t("applications.problem.title"), + message: problemMessage, + systemImage: "exclamationmark.triangle", + tint: .orange + ) + } + + HStack(alignment: .top, spacing: 16) { + applicationList + .frame(width: 330) + + applicationDetail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + .task { + await refreshApplications() + } + .alert(L.t("applications.confirm.title"), isPresented: $isShowingConfirmation) { + Button(L.t("button.cancel"), role: .cancel) {} + Button(removalButtonTitle, role: .destructive) { + removeSelectedApplications() + } + } message: { + Text(L.f( + "applications.confirm.message", + selectedApplications.count, + selectedLeftoverCount, + CleanMacFormatters.bytes(selectedRemovalSize) + )) + } + } + + private var applicationList: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(L.t("applications.list.title")) + .font(.headline) + Text(L.f("applications.list.count", applications.count)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + Task { await refreshApplications() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help(L.t("applications.refresh")) + .disabled(isScanning || isRemoving) + } + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(L.t("applications.search"), text: $searchText) + .textFieldStyle(.plain) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(.background.opacity(0.7), in: RoundedRectangle(cornerRadius: 7)) + + if !selectedApplications.isEmpty { + Label( + L.f( + "applications.selected.summary", + selectedApplications.count, + CleanMacFormatters.bytes(selectedRemovalSize) + ), + systemImage: "checkmark.square.fill" + ) + .font(.caption.weight(.medium)) + .foregroundStyle(.tint) + } + + Divider() + + if isScanning { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(L.t("applications.scanning")) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 180) + } else if filteredApplications.isEmpty { + ContentUnavailableView( + L.t("applications.empty.title"), + systemImage: "app.dashed", + description: Text(L.t("applications.empty.message")) + ) + .frame(minHeight: 180) + } else { + LazyVStack(spacing: 6) { + ForEach(filteredApplications) { application in + applicationRow(application) + } + } + } + + if scanIssueCount > 0 { + Label( + L.f("applications.scanIssues", scanIssueCount), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } + } + } + + private func applicationRow(_ application: InstalledApplication) -> some View { + let isSelected = application.id == selectedApplicationID + return HStack(spacing: 10) { + Toggle("", isOn: applicationSelectionBinding(for: application)) + .labelsHidden() + .toggleStyle(.checkbox) + .tint(isSelected ? .white : .accentColor) + .help(L.t("applications.selection.help")) + + Button { + showDetails(for: application) + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(application.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(CleanMacFormatters.bytes(application.sizeBytes)) + .font(.caption) + .foregroundStyle(isSelected ? Color.white.opacity(0.8) : Color.secondary) + } + + Spacer(minLength: 4) + + if !application.leftovers.isEmpty { + Text("+\(application.leftovers.count)") + .font(.caption.weight(.medium)) + .foregroundStyle(isSelected ? Color.white.opacity(0.9) : Color.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(isSelected ? Color.white : Color.primary) + .padding(.horizontal, 10) + .frame(height: 48) + .background( + isSelected ? Color.accentColor : Color.primary.opacity(0.045), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .disabled(isRemoving) + } + + private func showDetails(for application: InstalledApplication) { + selectedApplicationID = application.id + statusMessage = nil + problemMessage = nil + } + + @ViewBuilder + private var applicationDetail: some View { + if let application = selectedApplication { + InfoPanel { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "app.fill") + .font(.system(size: 32)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + + VStack(alignment: .leading, spacing: 4) { + Text(application.name) + .font(.title2.bold()) + Text(application.bundleIdentifier) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Spacer() + + Text(CleanMacFormatters.bytes(application.sizeBytes)) + .font(.headline) + } + + LabeledContent(L.t("applications.location")) { + Text(application.location == .user + ? L.t("applications.location.user") + : L.t("applications.location.shared")) + } + + VStack(alignment: .leading, spacing: 5) { + Text(L.t("results.detail.path")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(application.path) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + + HStack { + Button(L.t("button.reveal")) { + NSWorkspace.shared.activateFileViewerSelecting([ + URL(fileURLWithPath: application.path) + ]) + } + + Spacer() + } + + Divider() + + VStack(alignment: .leading, spacing: 5) { + Text(L.t("applications.leftovers.title")) + .font(.headline) + Text(L.t("applications.leftovers.message")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if application.leftovers.isEmpty { + Label( + L.t("applications.leftovers.empty"), + systemImage: "checkmark.circle" + ) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 8) { + ForEach(application.leftovers) { leftover in + Toggle(isOn: leftoverBinding( + for: leftover.id, + applicationID: application.id + )) { + HStack(spacing: 10) { + Image(systemName: leftover.kind.systemImage) + .foregroundStyle(.tint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(leftover.kind.title) + .font(.subheadline.weight(.medium)) + Spacer() + Text(CleanMacFormatters.bytes(leftover.sizeBytes)) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(leftover.path) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .toggleStyle(.checkbox) + .disabled(isRemoving) + } + } + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(L.f( + "applications.removal.summary", + selectedApplications.count, + selectedLeftoverCount, + CleanMacFormatters.bytes(selectedRemovalSize) + )) + .font(.subheadline.weight(.medium)) + Text(L.t("applications.removal.trash")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button(role: .destructive) { + isShowingConfirmation = true + } label: { + if isRemoving { + ProgressView() + .controlSize(.small) + } else { + Label(removalButtonTitle, systemImage: "trash") + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isRemoving || isScanning || selectedApplications.isEmpty) + } + } + } + } else { + InfoPanel { + ContentUnavailableView( + L.t("applications.detail.empty.title"), + systemImage: "cursorarrow.click", + description: Text(L.t("applications.detail.empty.message")) + ) + .frame(maxWidth: .infinity, minHeight: 360) + } + } + } + + private func applicationSelectionBinding(for application: InstalledApplication) -> Binding { + Binding( + get: { selectedApplicationIDs.contains(application.id) }, + set: { isSelected in + selectedApplicationID = application.id + statusMessage = nil + problemMessage = nil + + if isSelected { + selectedApplicationIDs.insert(application.id) + } else { + selectedApplicationIDs.remove(application.id) + selectedLeftoverIDsByApplication.removeValue(forKey: application.id) + } + } + ) + } + + private func leftoverBinding(for id: String, applicationID: String) -> Binding { + Binding( + get: { selectedLeftoverIDsByApplication[applicationID, default: []].contains(id) }, + set: { isSelected in + if isSelected { + selectedApplicationIDs.insert(applicationID) + selectedLeftoverIDsByApplication[applicationID, default: []].insert(id) + } else { + selectedLeftoverIDsByApplication[applicationID, default: []].remove(id) + } + } + ) + } + + @MainActor + private func refreshApplications() async { + guard !isScanning, !isRemoving else { + return + } + isScanning = true + problemMessage = nil + + let excludedBundleIdentifiers = Set([Bundle.main.bundleIdentifier].compactMap { $0 }) + let excludedApplicationPaths = Set([Bundle.main.bundleURL.path]) + let report = await Task.detached(priority: .userInitiated) { + InstalledApplicationScanner( + excludedBundleIdentifiers: excludedBundleIdentifiers, + excludedApplicationPaths: excludedApplicationPaths + ).scan() + }.value + + applications = report.applications + scanIssueCount = report.issues.count + let validApplicationIDs = Set(applications.map(\.id)) + selectedApplicationIDs.formIntersection(validApplicationIDs) + selectedLeftoverIDsByApplication = selectedLeftoverIDsByApplication.reduce(into: [:]) { + result, entry in + guard let application = applications.first(where: { $0.id == entry.key }), + selectedApplicationIDs.contains(entry.key) else { + return + } + let validLeftoverIDs = Set(application.leftovers.map(\.id)) + result[entry.key] = entry.value.intersection(validLeftoverIDs) + } + if let selectedApplicationID, + !applications.contains(where: { $0.id == selectedApplicationID }) { + self.selectedApplicationID = nil + } + isScanning = false + } + + private func removeSelectedApplications() { + let removalApplications = selectedApplications + guard !isRemoving, !removalApplications.isEmpty else { + return + } + isRemoving = true + statusMessage = nil + problemMessage = nil + + let selectedLeftoverIDs = selectedLeftoverIDsByApplication + let excludedBundleIdentifiers = Set([Bundle.main.bundleIdentifier].compactMap { $0 }) + let excludedApplicationPaths = Set([Bundle.main.bundleURL.path]) + + Task { + let reports = await Task.detached(priority: .userInitiated) { + removalApplications.map { application in + let plan = ApplicationRemovalPlanner( + excludedBundleIdentifiers: excludedBundleIdentifiers, + excludedApplicationPaths: excludedApplicationPaths + ).plan( + for: application, + selectedLeftoverIDs: selectedLeftoverIDs[application.id] ?? [] + ) + return (application.id, ApplicationRemovalExecutor().execute(plan: plan)) + } + }.value + + isRemoving = false + let movedApplicationIDs = Set(reports.compactMap { applicationID, report in + report.applicationMoved ? applicationID : nil + }) + let movedLeftoverCount = reports.reduce(0) { count, entry in + count + entry.1.movedItems.count - (entry.1.applicationMoved ? 1 : 0) + } + let totalMovedBytes = reports.reduce(0) { $0 + $1.1.totalMovedBytes } + let failedCount = reports.reduce(0) { $0 + $1.1.failedItems.count } + let rejectedCount = reports.reduce(0) { $0 + $1.1.rejectedItems.count } + + if !movedApplicationIDs.isEmpty { + statusMessage = L.f( + "applications.removal.success", + movedApplicationIDs.count, + movedLeftoverCount, + CleanMacFormatters.bytes(totalMovedBytes) + ) + applications.removeAll { movedApplicationIDs.contains($0.id) } + selectedApplicationIDs.subtract(movedApplicationIDs) + for applicationID in movedApplicationIDs { + selectedLeftoverIDsByApplication.removeValue(forKey: applicationID) + } + if let selectedApplicationID, movedApplicationIDs.contains(selectedApplicationID) { + self.selectedApplicationID = selectedApplications.first?.id + } + } + + if failedCount > 0 || rejectedCount > 0 { + problemMessage = L.f( + "applications.removal.problems", + failedCount, + rejectedCount + ) + } + } + } +} + +private extension ApplicationLeftoverKind { + var title: String { + switch self { + case .cache: L.t("applications.leftover.cache") + case .preferences: L.t("applications.leftover.preferences") + case .savedApplicationState: L.t("applications.leftover.savedState") + case .logs: L.t("applications.leftover.logs") + } + } + + var systemImage: String { + switch self { + case .cache: "shippingbox" + case .preferences: "slider.horizontal.3" + case .savedApplicationState: "macwindow" + case .logs: "doc.text" + } + } +} diff --git a/CleanMac/Views/MainWindowView.swift b/CleanMac/Views/MainWindowView.swift index 4def43b..b8b058e 100644 --- a/CleanMac/Views/MainWindowView.swift +++ b/CleanMac/Views/MainWindowView.swift @@ -65,6 +65,13 @@ struct MainWindowView: View { .onChange(of: selectedAreaIDs) { _, newValue in CleanMacScanPreferences.storeSelectedAreaIDs(newValue) } + .onChange(of: safeModeEnabled) { _, isEnabled in + guard isEnabled else { + return + } + + restrictSelectionToSafeItems() + } } @ViewBuilder @@ -95,6 +102,7 @@ struct MainWindowView: View { results: scanResults, report: scanReport, scanError: scanError, + safeModeEnabled: safeModeEnabled, selectedResultIDs: $selectedResultIDs, isCleaning: isCleaning, isRestoring: isRestoring, @@ -104,8 +112,13 @@ struct MainWindowView: View { restoreProblemMessage: restoreProblemMessage, cleanupHistory: cleanupHistory, onConfirmCleanup: cleanupSelectedItems, - onRestoreHistoryItem: restoreHistoryItem + onRestoreHistoryItem: restoreHistoryItem, + onOpenPermissions: { + selectedSectionID = CleanMacSection.permissions.rawValue + } ) + case .applications: + ApplicationsView() case .permissions: PermissionsView() case .settings: @@ -203,7 +216,13 @@ struct MainWindowView: View { return } - let selectedItems = scanItems.filter { selectedResultIDs.contains($0.id) } + if safeModeEnabled { + restrictSelectionToSafeItems() + } + + let selectedItems = scanItems.filter { + selectedResultIDs.contains($0.id) && (!safeModeEnabled || $0.risk == .safe) + } guard !selectedItems.isEmpty else { cleanupProblemMessage = L.t("cleanup.noneSelected") return @@ -249,6 +268,11 @@ struct MainWindowView: View { } } + private func restrictSelectionToSafeItems() { + let safeItemIDs = Set(scanItems.filter { $0.risk == .safe }.map(\.id)) + selectedResultIDs.formIntersection(safeItemIDs) + } + private func restoreHistoryItem(_ historyID: String) { guard !isRestoring else { return diff --git a/CleanMac/Views/PermissionsView.swift b/CleanMac/Views/PermissionsView.swift index 283513f..87588c2 100644 --- a/CleanMac/Views/PermissionsView.swift +++ b/CleanMac/Views/PermissionsView.swift @@ -3,6 +3,8 @@ import SwiftUI struct PermissionsView: View { @State private var fullDiskAccess = FullDiskAccessChecker().check() + @State private var finderAutomationPermission: FinderAutomationPermission? + @State private var isRequestingFinderAutomation = false var body: some View { PageContainer { @@ -14,8 +16,20 @@ struct PermissionsView: View { ) VStack(spacing: 10) { - ForEach(CleanMacCatalog.permissions(fullDiskAccess: fullDiskAccess)) { permission in - PermissionRow(permission: permission) + ForEach(CleanMacCatalog.permissions( + fullDiskAccess: fullDiskAccess, + finderAutomationPermission: finderAutomationPermission + )) { permission in + if permission.id == "automation" { + PermissionRow( + permission: permission, + actionTitle: automationActionTitle, + isActionInProgress: isRequestingFinderAutomation || finderAutomationPermission == nil, + action: handleAutomationAction + ) + } else { + PermissionRow(permission: permission) + } } } @@ -23,7 +37,7 @@ struct PermissionsView: View { Spacer() Button { - refreshFullDiskAccess() + refreshAccess() } label: { Label(L.t("button.refreshAccess"), systemImage: "arrow.clockwise") } @@ -36,10 +50,61 @@ struct PermissionsView: View { } } } + .task { + await refreshFinderAutomationPermission() + } + } + + private var automationActionTitle: String? { + switch finderAutomationPermission { + case .notDetermined: + L.t("button.requestAutomation") + case .denied: + L.t("button.openAutomationSettings") + default: + nil + } } - private func refreshFullDiskAccess() { + private func refreshAccess() { fullDiskAccess = FullDiskAccessChecker().check() + Task { + await refreshFinderAutomationPermission() + } + } + + private func refreshFinderAutomationPermission() async { + guard !isRequestingFinderAutomation else { + return + } + + finderAutomationPermission = nil + finderAutomationPermission = await CleanMacAutomationService.checkFinderPermission() + } + + private func handleAutomationAction() { + switch finderAutomationPermission { + case .notDetermined: + requestFinderAutomationPermission() + case .denied: + openAutomationSettings() + default: + break + } + } + + private func requestFinderAutomationPermission() { + guard !isRequestingFinderAutomation else { + return + } + + isRequestingFinderAutomation = true + finderAutomationPermission = nil + + Task { + finderAutomationPermission = await CleanMacAutomationService.requestFinderPermission() + isRequestingFinderAutomation = false + } } private func openPrivacySettings() { @@ -48,10 +113,20 @@ struct PermissionsView: View { } NSWorkspace.shared.open(url) } + + private func openAutomationSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else { + return + } + NSWorkspace.shared.open(url) + } } private struct PermissionRow: View { let permission: PermissionItem + var actionTitle: String? = nil + var isActionInProgress = false + var action: (() -> Void)? = nil var body: some View { InfoPanel { @@ -66,16 +141,27 @@ private struct PermissionRow: View { .font(.headline) Text(permission.detail) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } Spacer() - Text(permission.state.title) - .font(.caption.weight(.medium)) - .foregroundStyle(permission.state.foregroundStyle) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(permission.state.backgroundStyle, in: Capsule()) + VStack(alignment: .trailing, spacing: 8) { + Text(permission.state.title) + .font(.caption.weight(.medium)) + .foregroundStyle(permission.state.foregroundStyle) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(permission.state.backgroundStyle, in: Capsule()) + + if isActionInProgress { + ProgressView() + .controlSize(.small) + } else if let actionTitle, let action { + Button(actionTitle, action: action) + .controlSize(.small) + } + } } } } @@ -88,7 +174,9 @@ private extension PermissionState { case .limited: .orange case .unknown: .secondary case .recommended: .blue - case .later: .secondary + case .notRequested: .blue + case .denied: .orange + case .unavailable, .checking: .secondary } } @@ -98,7 +186,9 @@ private extension PermissionState { case .limited: .orange.opacity(0.14) case .unknown: .secondary.opacity(0.12) case .recommended: .blue.opacity(0.12) - case .later: .secondary.opacity(0.12) + case .notRequested: .blue.opacity(0.12) + case .denied: .orange.opacity(0.14) + case .unavailable, .checking: .secondary.opacity(0.12) } } } diff --git a/CleanMac/Views/ResultsView.swift b/CleanMac/Views/ResultsView.swift index e112a35..c7624d6 100644 --- a/CleanMac/Views/ResultsView.swift +++ b/CleanMac/Views/ResultsView.swift @@ -6,6 +6,7 @@ struct ResultsView: View { let results: [ScanResult] let report: CleanupScanReport? let scanError: String? + let safeModeEnabled: Bool @Binding var selectedResultIDs: Set let isCleaning: Bool let isRestoring: Bool @@ -16,13 +17,14 @@ struct ResultsView: View { let cleanupHistory: [CleanupHistoryItem] let onConfirmCleanup: () -> Void let onRestoreHistoryItem: (String) -> Void + let onOpenPermissions: () -> Void @State private var isShowingCleanupConfirmation = false @State private var selectedCategory: CleanupCategory? @State private var focusedResultID: String? private var selectedResults: [ScanResult] { - results.filter { selectedResultIDs.contains($0.id) } + results.filter(isResultSelected) } private var selectedSizeBytes: Int64 { @@ -54,13 +56,56 @@ struct ResultsView: View { category: category, count: categoryResults.count, sizeBytes: categoryResults.reduce(0) { $0 + $1.sizeBytes }, - selectedCount: categoryResults.filter { selectedResultIDs.contains($0.id) }.count, + selectedCount: categoryResults.filter(isResultSelected).count, safeCount: categoryResults.filter { $0.risk == .safe }.count, reviewCount: categoryResults.filter { $0.risk == .review }.count ) } } + private var unavailableScanAreas: [UnavailableScanArea] { + guard let report else { + return [] + } + + var failedPathsByCategory: [CleanupCategory: Set] = [:] + for issue in report.issues { + failedPathsByCategory[issue.category, default: []].insert(issue.path) + } + + var areas = failedPathsByCategory.map { category, paths in + UnavailableScanArea( + category: category, + paths: paths.sorted { $0.localizedStandardCompare($1) == .orderedAscending }, + reason: .readFailed + ) + } + + let failedCategories = Set(failedPathsByCategory.keys) + areas.append(contentsOf: report.summaries.compactMap { summary in + guard !summary.isAvailable, !failedCategories.contains(summary.category) else { + return nil + } + + let paths = summary.scannedPath + .components(separatedBy: ", ") + .filter { !$0.isEmpty } + + return UnavailableScanArea( + category: summary.category, + paths: paths, + reason: .notFound + ) + }) + + let categoryOrder = Dictionary( + uniqueKeysWithValues: CleanupCategory.allCases.enumerated().map { ($1, $0) } + ) + return areas.sorted { + categoryOrder[$0.category, default: .max] < categoryOrder[$1.category, default: .max] + } + } + var body: some View { PageContainer { VStack(alignment: .leading, spacing: 16) { @@ -87,6 +132,15 @@ struct ResultsView: View { statusBanners + if safeModeEnabled, results.contains(where: { $0.risk == .review }) { + StatusBanner( + title: L.t("results.safeMode.title"), + message: L.t("results.safeMode.message"), + systemImage: "lock.shield", + tint: .blue + ) + } + if results.isEmpty { emptyResultsPanel cleanupHistoryPanel @@ -145,7 +199,12 @@ struct ResultsView: View { ) } - if let scanError { + if !unavailableScanAreas.isEmpty { + ScanAvailabilityPanel( + areas: unavailableScanAreas, + onOpenPermissions: onOpenPermissions + ) + } else if let scanError { StatusBanner( title: L.t("banner.scanIssues.title"), message: scanError, @@ -209,7 +268,7 @@ struct ResultsView: View { } Button { - selectedResultIDs = Set(visibleResults.map(\.id)) + selectedResultIDs = Set(visibleResults.filter(isResultSelectable).map(\.id)) } label: { Label(L.t("button.selectVisible"), systemImage: "checklist.checked") } @@ -313,10 +372,11 @@ struct ResultsView: View { ResultRow( result: result, isFocused: focusedResult?.id == result.id, + isSelectionEnabled: isResultSelectable(result), isSelected: Binding( - get: { selectedResultIDs.contains(result.id) }, + get: { isResultSelected(result) }, set: { isSelected in - if isSelected { + if isSelected, isResultSelectable(result) { selectedResultIDs.insert(result.id) } else { selectedResultIDs.remove(result.id) @@ -398,11 +458,144 @@ struct ResultsView: View { ) } + private func isResultSelectable(_ result: ScanResult) -> Bool { + !safeModeEnabled || result.risk == .safe + } + + private func isResultSelected(_ result: ScanResult) -> Bool { + isResultSelectable(result) && selectedResultIDs.contains(result.id) + } + private func reveal(_ path: String) { guard path != L.t("history.trashPath.unknown") else { return } - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + + let url = URL(fileURLWithPath: path) + Task { + let revealedWithAutomation = await CleanMacAutomationService.revealInFinder(url) + if !revealedWithAutomation { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + } +} + +private struct UnavailableScanArea: Identifiable { + enum Reason { + case readFailed + case notFound + } + + let category: CleanupCategory + let paths: [String] + let reason: Reason + + var id: String { + "\(category.rawValue)-\(reason == .readFailed ? "read" : "missing")" + } +} + +private struct ScanAvailabilityPanel: View { + let areas: [UnavailableScanArea] + let onOpenPermissions: () -> Void + + private var hasReadFailures: Bool { + areas.contains { $0.reason == .readFailed } + } + + var body: some View { + InfoPanel { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(L.t("results.availability.title")) + .font(.headline) + Spacer() + Text(L.f("results.availability.count", areas.count)) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + + Text(L.t( + hasReadFailures + ? "results.availability.message.readFailed" + : "results.availability.message.notFound" + )) + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(spacing: 8) { + ForEach(areas) { area in + ScanAvailabilityRow(area: area) + } + } + + if hasReadFailures { + HStack { + Text(L.t("results.availability.permissionsHint")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(action: onOpenPermissions) { + Label(L.t("button.reviewPermissions"), systemImage: "lock.shield") + } + } + } + } + } + } +} + +private struct ScanAvailabilityRow: View { + let area: UnavailableScanArea + + private var cleanupArea: CleanupArea { + CleanMacCatalog.area(for: area.category) + } + + private var statusTitle: String { + switch area.reason { + case .readFailed: + L.t("results.availability.readFailed") + case .notFound: + L.t("results.availability.notFound") + } + } + + private var statusColor: Color { + area.reason == .readFailed ? .orange : .secondary + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: cleanupArea.systemImage) + .frame(width: 24) + .foregroundStyle(.tint) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(cleanupArea.title) + .font(.subheadline.weight(.semibold)) + Text(statusTitle) + .font(.caption.weight(.medium)) + .foregroundStyle(statusColor) + } + + ForEach(area.paths, id: \.self) { path in + Text(path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(10) + .background(Color.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) } } @@ -521,6 +714,7 @@ private struct CategorySummaryRow: View { private struct ResultRow: View { let result: ScanResult let isFocused: Bool + let isSelectionEnabled: Bool @Binding var isSelected: Bool let onFocus: () -> Void @@ -531,6 +725,8 @@ private struct ResultRow: View { .toggleStyle(.checkbox) .labelsHidden() .padding(.top, 2) + .disabled(!isSelectionEnabled) + .help(isSelectionEnabled ? "" : L.t("results.safeMode.locked")) Image(systemName: result.isDirectory ? "folder" : "doc") .font(.title3) @@ -573,6 +769,12 @@ private struct ResultRow: View { .font(.headline) .lineLimit(1) .minimumScaleFactor(0.75) + if !isSelectionEnabled { + Image(systemName: "lock.fill") + .foregroundStyle(.secondary) + .help(L.t("results.safeMode.locked")) + .accessibilityLabel(L.t("results.safeMode.locked")) + } if isFocused { Image(systemName: "sidebar.right") .foregroundStyle(.tint) diff --git a/CleanMac/Views/StatusMenuView.swift b/CleanMac/Views/StatusMenuView.swift index c24585f..9662bb4 100644 --- a/CleanMac/Views/StatusMenuView.swift +++ b/CleanMac/Views/StatusMenuView.swift @@ -333,9 +333,27 @@ private struct DiskUsageSnapshot: Equatable { static func current() -> DiskUsageSnapshot { let homeURL = FileManager.default.homeDirectoryForCurrentUser let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: homeURL.path)) ?? [:] - let totalBytes = numberValue(attributes[.systemSize]) - let freeBytes = numberValue(attributes[.systemFreeSize]) - let resourceValues = try? homeURL.resourceValues(forKeys: [.volumeLocalizedNameKey]) + let resourceValues = try? homeURL.resourceValues(forKeys: [ + .volumeLocalizedNameKey, + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey + ]) + + let resourceTotalBytes = Int64(resourceValues?.volumeTotalCapacity ?? 0) + let systemTotalBytes = numberValue(attributes[.systemSize]) + let totalBytes = resourceTotalBytes > 0 ? resourceTotalBytes : systemTotalBytes + + let importantUsageBytes = resourceValues?.volumeAvailableCapacityForImportantUsage ?? 0 + let availableBytes = Int64(resourceValues?.volumeAvailableCapacity ?? 0) + let systemFreeBytes = numberValue(attributes[.systemFreeSize]) + let freeBytes = if importantUsageBytes > 0 { + importantUsageBytes + } else if availableBytes > 0 { + availableBytes + } else { + systemFreeBytes + } return DiskUsageSnapshot( volumeName: resourceValues?.volumeLocalizedName, diff --git a/CleanMac/en.lproj/InfoPlist.strings b/CleanMac/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..e045187 --- /dev/null +++ b/CleanMac/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSAppleEventsUsageDescription" = "CleanMac uses Automation to ask Finder to reveal cleanup items you choose."; diff --git a/CleanMac/en.lproj/Localizable.strings b/CleanMac/en.lproj/Localizable.strings index 100ae13..3f4a0b9 100644 --- a/CleanMac/en.lproj/Localizable.strings +++ b/CleanMac/en.lproj/Localizable.strings @@ -4,6 +4,7 @@ "section.dashboard" = "Dashboard"; "section.scan" = "Scan"; "section.results" = "Results"; +"section.applications" = "Applications"; "section.permissions" = "Permissions"; "section.settings" = "Settings"; @@ -51,6 +52,9 @@ "button.cancel" = "Cancel"; "button.openSystemSettings" = "Open System Settings"; "button.refreshAccess" = "Refresh Access"; +"button.requestAutomation" = "Request Access"; +"button.openAutomationSettings" = "Open Automation"; +"button.reviewPermissions" = "Review Permissions"; "button.reveal" = "Reveal"; "button.restore" = "Restore"; "button.restoring" = "Restoring"; @@ -116,9 +120,19 @@ "results.empty.description" = "Run a scan to review cleanup candidates."; "results.readyGroups" = "%d items ready"; "results.selected.summary" = "%d selected, %@"; +"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.cleanupDisabled.help" = "Cleanup moves allowlisted items to Trash after confirmation."; "results.summary.items" = "%d items"; "results.summary.unavailable" = "Not available"; +"results.availability.title" = "Some areas were not checked"; +"results.availability.count" = "%d area(s)"; +"results.availability.message.readFailed" = "CleanMac could not read these locations. Other available areas were still scanned."; +"results.availability.message.notFound" = "These optional locations were not found on this Mac. Other available areas were scanned normally."; +"results.availability.readFailed" = "Could not read"; +"results.availability.notFound" = "Folder not found"; +"results.availability.permissionsHint" = "If the folder exists, check Full Disk Access."; "results.estimated" = "estimated"; "results.metric.total" = "Items"; "results.metric.space" = "Potential"; @@ -209,13 +223,21 @@ "permission.fullDisk.detail.granted" = "Granted. CleanMac can inspect protected metadata probes: %d of %d."; "permission.fullDisk.detail.limited" = "Limited. CleanMac can inspect protected metadata probes: %d of %d. Enable Full Disk Access for deeper scans."; "permission.fullDisk.detail.unknown" = "Status unknown. No protected probe folders were available on this Mac."; -"permission.automation.title" = "Automation"; -"permission.automation.detail" = "Optional for opening Finder and System Settings shortcuts."; +"permission.automation.title" = "Finder Automation"; +"permission.automation.detail.checking" = "Checking the Finder Automation status."; +"permission.automation.detail.granted" = "Allowed. CleanMac can use Apple Events to reveal selected items in Finder. This access is optional for scanning and cleanup."; +"permission.automation.detail.notRequested" = "Not requested. Allow CleanMac to use Apple Events to reveal selected items in Finder. Scanning and cleanup do not require it."; +"permission.automation.detail.denied" = "Finder control is denied. Enable CleanMac → Finder in Privacy & Security → Automation."; +"permission.automation.detail.finderUnavailable" = "Finder is not running. Open Finder, then refresh access."; +"permission.automation.detail.unavailable" = "macOS could not determine Finder Automation access. Refresh or check Automation settings."; "permission.state.granted" = "Granted"; "permission.state.limited" = "Limited"; "permission.state.unknown" = "Unknown"; "permission.state.recommended" = "Recommended"; -"permission.state.later" = "Later"; +"permission.state.notRequested" = "Not requested"; +"permission.state.denied" = "Denied"; +"permission.state.unavailable" = "Unavailable"; +"permission.state.checking" = "Checking"; "settings.title" = "Settings"; "settings.subtitle" = "Cleanup behavior and app controls"; @@ -262,3 +284,40 @@ "risk.review" = "Review"; "size.zero" = "0 KB"; "date.unknown" = "Unknown"; + +"applications.title" = "Applications"; +"applications.subtitle" = "Review third-party apps before moving them to Trash"; +"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.status.title" = "Removal complete"; +"applications.problem.title" = "Removal needs review"; +"applications.list.title" = "Installed apps"; +"applications.list.count" = "%d apps"; +"applications.refresh" = "Refresh applications"; +"applications.search" = "Search apps"; +"applications.scanning" = "Scanning Applications folders…"; +"applications.empty.title" = "No applications found"; +"applications.empty.message" = "No matching third-party apps are available in the supported folders."; +"applications.scanIssues" = "%d folder(s) could not be read"; +"applications.location" = "Installed for"; +"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.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.selected.summary" = "%d app(s) selected · %@"; +"applications.selection.help" = "Include this application in removal"; +"applications.removal.summary" = "%d app(s) · %d leftover(s) · %@ total"; +"applications.removal.trash" = "Each app is moved first. Nothing is permanently deleted."; +"applications.remove.button" = "Delete Application"; +"applications.remove.multiple" = "Delete Applications (%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.removal.success" = "Moved %d app(s) and %d leftover(s), %@ total, to Trash."; +"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."; diff --git a/CleanMac/ru.lproj/InfoPlist.strings b/CleanMac/ru.lproj/InfoPlist.strings new file mode 100644 index 0000000..8de31d6 --- /dev/null +++ b/CleanMac/ru.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSAppleEventsUsageDescription" = "CleanMac использует Автоматизацию, чтобы Finder показывал выбранные тобой элементы очистки."; diff --git a/CleanMac/ru.lproj/Localizable.strings b/CleanMac/ru.lproj/Localizable.strings index 8f2cfa9..842b327 100644 --- a/CleanMac/ru.lproj/Localizable.strings +++ b/CleanMac/ru.lproj/Localizable.strings @@ -4,6 +4,7 @@ "section.dashboard" = "Обзор"; "section.scan" = "Сканирование"; "section.results" = "Результаты"; +"section.applications" = "Приложения"; "section.permissions" = "Доступы"; "section.settings" = "Настройки"; @@ -51,6 +52,9 @@ "button.cancel" = "Отмена"; "button.openSystemSettings" = "Открыть настройки системы"; "button.refreshAccess" = "Обновить доступы"; +"button.requestAutomation" = "Запросить доступ"; +"button.openAutomationSettings" = "Открыть Автоматизацию"; +"button.reviewPermissions" = "Проверить доступы"; "button.reveal" = "Показать"; "button.restore" = "Восстановить"; "button.restoring" = "Восстановление"; @@ -116,9 +120,19 @@ "results.empty.description" = "Запусти сканирование, чтобы увидеть кандидатов на очистку."; "results.readyGroups" = "Готово к проверке: %d"; "results.selected.summary" = "Выбрано: %d, %@"; +"results.safeMode.title" = "Безопасный режим включён"; +"results.safeMode.message" = "Элементы «Нужно проверить» остаются видимыми, но выбрать их можно только после отключения безопасного режима в Настройках."; +"results.safeMode.locked" = "Заблокировано безопасным режимом"; "results.cleanupDisabled.help" = "Очистка перемещает разрешённые элементы в Корзину после подтверждения."; "results.summary.items" = "Элементов: %d"; "results.summary.unavailable" = "Недоступно"; +"results.availability.title" = "Некоторые области не проверены"; +"results.availability.count" = "Областей: %d"; +"results.availability.message.readFailed" = "CleanMac не смог прочитать эти места. Остальные доступные области проверены."; +"results.availability.message.notFound" = "Эти необязательные папки не найдены на этом Mac. Остальные доступные области проверены как обычно."; +"results.availability.readFailed" = "Не удалось прочитать"; +"results.availability.notFound" = "Папка не найдена"; +"results.availability.permissionsHint" = "Если папка существует, проверь Полный доступ к диску."; "results.estimated" = "оценка"; "results.metric.total" = "Элементы"; "results.metric.space" = "Потенциально"; @@ -209,13 +223,21 @@ "permission.fullDisk.detail.granted" = "Разрешён. CleanMac может проверять защищённые метаданные: %d из %d."; "permission.fullDisk.detail.limited" = "Ограничен. CleanMac может проверять защищённые метаданные: %d из %d. Включи Полный доступ к диску для глубокого сканирования."; "permission.fullDisk.detail.unknown" = "Статус неизвестен. На этом Mac не найдены защищённые папки для проверки."; -"permission.automation.title" = "Автоматизация"; -"permission.automation.detail" = "Опционально для открытия Finder и системных настроек."; +"permission.automation.title" = "Автоматизация Finder"; +"permission.automation.detail.checking" = "Проверяю статус автоматизации Finder."; +"permission.automation.detail.granted" = "Разрешено. CleanMac может через Apple Events показывать выбранные элементы в Finder. Для сканирования и очистки этот доступ не обязателен."; +"permission.automation.detail.notRequested" = "Не запрошено. Разреши CleanMac через Apple Events показывать выбранные элементы в Finder. Для сканирования и очистки доступ не нужен."; +"permission.automation.detail.denied" = "Управление Finder запрещено. Включи CleanMac → Finder в разделе «Конфиденциальность и безопасность» → «Автоматизация»."; +"permission.automation.detail.finderUnavailable" = "Finder не запущен. Открой Finder и обнови доступы."; +"permission.automation.detail.unavailable" = "macOS не смогла определить доступ к автоматизации Finder. Обнови статус или проверь раздел «Автоматизация»."; "permission.state.granted" = "Разрешён"; "permission.state.limited" = "Ограничен"; "permission.state.unknown" = "Неизвестно"; "permission.state.recommended" = "Рекомендуется"; -"permission.state.later" = "Позже"; +"permission.state.notRequested" = "Не запрошен"; +"permission.state.denied" = "Запрещён"; +"permission.state.unavailable" = "Недоступен"; +"permission.state.checking" = "Проверка"; "settings.title" = "Настройки"; "settings.subtitle" = "Поведение очистки и управление приложением"; @@ -262,3 +284,40 @@ "risk.review" = "Проверить"; "size.zero" = "0 КБ"; "date.unknown" = "Неизвестно"; + +"applications.title" = "Приложения"; +"applications.subtitle" = "Проверка сторонних программ перед перемещением в Корзину"; +"applications.safety.title" = "Безопасные границы удаления"; +"applications.safety.message" = "Показаны только приложения непосредственно из папок Applications. Личные данные, Application Support, Containers и системные приложения исключены."; +"applications.status.title" = "Удаление завершено"; +"applications.problem.title" = "Нужно проверить удаление"; +"applications.list.title" = "Установленные приложения"; +"applications.list.count" = "Приложений: %d"; +"applications.refresh" = "Обновить приложения"; +"applications.search" = "Поиск приложений"; +"applications.scanning" = "Проверяю папки Applications…"; +"applications.empty.title" = "Приложения не найдены"; +"applications.empty.message" = "В поддерживаемых папках нет подходящих сторонних приложений."; +"applications.scanIssues" = "Не удалось прочитать папок: %d"; +"applications.location" = "Установлено для"; +"applications.location.user" = "Текущего пользователя"; +"applications.location.shared" = "Всех пользователей"; +"applications.leftovers.title" = "Связанные остатки"; +"applications.leftovers.message" = "Необязательно и изначально не выбрано. Предлагаются только точные пути кэша, настроек, сохранённого состояния и логов по bundle ID."; +"applications.leftovers.empty" = "Точные связанные остатки не найдены"; +"applications.leftover.cache" = "Кэш"; +"applications.leftover.preferences" = "Настройки"; +"applications.leftover.savedState" = "Сохранённое состояние"; +"applications.leftover.logs" = "Логи"; +"applications.selected.summary" = "Выбрано приложений: %d · %@"; +"applications.selection.help" = "Добавить приложение к удалению"; +"applications.removal.summary" = "Приложений: %d · остатков: %d · всего %@"; +"applications.removal.trash" = "Сначала перемещается каждое приложение. Ничего не удаляется навсегда."; +"applications.remove.button" = "Удалить приложение"; +"applications.remove.multiple" = "Удалить приложения (%d)"; +"applications.confirm.title" = "Переместить выбранные приложения в Корзину?"; +"applications.confirm.message" = "CleanMac переместит выбранные приложения: %d, остатки: %d, общий объём: %@, в Корзину. Это действие всегда требует подтверждения."; +"applications.removal.success" = "В Корзину перемещено приложений: %d, остатков: %d, общий объём: %@."; +"applications.removal.problems" = "Некоторые элементы не перемещены. Ошибок: %d, отклонено проверкой безопасности: %d."; +"applications.detail.empty.title" = "Выбери приложение"; +"applications.detail.empty.message" = "Выбери программу, чтобы проверить её путь и необязательные точные остатки."; diff --git a/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift b/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift new file mode 100644 index 0000000..a1f74df --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift @@ -0,0 +1,672 @@ +import Foundation + +public enum InstalledApplicationLocation: String, Sendable { + case shared + case user +} + +public enum ApplicationLeftoverKind: String, CaseIterable, Sendable { + case cache + case preferences + case savedApplicationState + case logs +} + +public struct ApplicationLeftover: Identifiable, Equatable, Sendable { + public let id: String + public let kind: ApplicationLeftoverKind + public let path: String + public let sizeBytes: Int64 + public let isSizeEstimate: Bool + + public init( + kind: ApplicationLeftoverKind, + path: String, + sizeBytes: Int64, + isSizeEstimate: Bool + ) { + self.id = path + self.kind = kind + self.path = path + self.sizeBytes = sizeBytes + self.isSizeEstimate = isSizeEstimate + } +} + +public struct InstalledApplication: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let bundleIdentifier: String + public let path: String + public let sizeBytes: Int64 + public let isSizeEstimate: Bool + public let location: InstalledApplicationLocation + public let leftovers: [ApplicationLeftover] + + public init( + name: String, + bundleIdentifier: String, + path: String, + sizeBytes: Int64, + isSizeEstimate: Bool, + location: InstalledApplicationLocation, + leftovers: [ApplicationLeftover] + ) { + self.id = path + self.name = name + self.bundleIdentifier = bundleIdentifier + self.path = path + self.sizeBytes = sizeBytes + self.isSizeEstimate = isSizeEstimate + self.location = location + self.leftovers = leftovers + } + + public var totalReviewSizeBytes: Int64 { + sizeBytes + leftovers.reduce(0) { $0 + $1.sizeBytes } + } +} + +public struct InstalledApplicationScanIssue: Identifiable, Equatable, Sendable { + public let id: String + public let path: String + public let message: String + + public init(path: String, message: String) { + self.id = path + self.path = path + self.message = message + } +} + +public struct InstalledApplicationScanReport: Equatable, Sendable { + public let applications: [InstalledApplication] + public let issues: [InstalledApplicationScanIssue] + + public init( + applications: [InstalledApplication], + issues: [InstalledApplicationScanIssue] + ) { + self.applications = applications + self.issues = issues + } +} + +public struct InstalledApplicationScanner { + private let fileManager: FileManager + private let applicationDirectories: [URL] + private let homeDirectory: URL + private let excludedBundleIdentifiers: Set + private let excludedApplicationPaths: Set + private let maxDescendantsPerItem: Int + + public init( + fileManager: FileManager = .default, + applicationDirectories: [URL]? = nil, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + excludedBundleIdentifiers: Set = [], + excludedApplicationPaths: Set = [], + maxDescendantsPerItem: Int = 25_000 + ) { + self.fileManager = fileManager + self.homeDirectory = homeDirectory + self.applicationDirectories = applicationDirectories ?? [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectory.appending(path: "Applications", directoryHint: .isDirectory) + ] + self.excludedBundleIdentifiers = excludedBundleIdentifiers + self.excludedApplicationPaths = Set(excludedApplicationPaths.map(Self.canonicalPath)) + self.maxDescendantsPerItem = max(1, maxDescendantsPerItem) + } + + public func scan() -> InstalledApplicationScanReport { + var applications: [InstalledApplication] = [] + var issues: [InstalledApplicationScanIssue] = [] + + for (index, directory) in applicationDirectories.enumerated() { + guard fileManager.fileExists(atPath: directory.path) else { + continue + } + + let childURLs: [URL] + do { + childURLs = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) + } catch { + issues.append(InstalledApplicationScanIssue( + path: directory.path, + message: error.localizedDescription + )) + continue + } + + let location: InstalledApplicationLocation = index == 1 ? .user : .shared + for url in childURLs where url.pathExtension.lowercased() == "app" { + guard let application = application(at: url, location: location) else { + continue + } + applications.append(application) + } + } + + let uniqueApplications = Dictionary( + applications.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ).values.sorted { + let nameComparison = $0.name.localizedStandardCompare($1.name) + return nameComparison == .orderedSame ? $0.path < $1.path : nameComparison == .orderedAscending + } + + return InstalledApplicationScanReport( + applications: uniqueApplications, + issues: issues + ) + } + + private func application( + at url: URL, + location: InstalledApplicationLocation + ) -> InstalledApplication? { + guard !isSymbolicLink(url) else { + return nil + } + + let canonicalPath = Self.canonicalPath(url.path) + guard !excludedApplicationPaths.contains(canonicalPath), + let metadata = Self.bundleMetadata(at: url), + Self.isSafeBundleIdentifier(metadata.bundleIdentifier), + !metadata.bundleIdentifier.hasPrefix("com.apple."), + !excludedBundleIdentifiers.contains(metadata.bundleIdentifier) else { + return nil + } + + let appSize = measuredSize(of: url) + let leftovers = ApplicationLeftoverKind.allCases.compactMap { + leftover(for: $0, bundleIdentifier: metadata.bundleIdentifier) + } + + return InstalledApplication( + name: metadata.name ?? url.deletingPathExtension().lastPathComponent, + bundleIdentifier: metadata.bundleIdentifier, + path: canonicalPath, + sizeBytes: appSize.bytes, + isSizeEstimate: appSize.isEstimate, + location: location, + leftovers: leftovers + ) + } + + private func leftover( + for kind: ApplicationLeftoverKind, + bundleIdentifier: String + ) -> ApplicationLeftover? { + let url = Self.leftoverURL( + for: kind, + bundleIdentifier: bundleIdentifier, + homeDirectory: homeDirectory + ) + guard fileManager.fileExists(atPath: url.path), !isSymbolicLink(url) else { + return nil + } + + let size = measuredSize(of: url) + return ApplicationLeftover( + kind: kind, + path: Self.canonicalPath(url.path), + sizeBytes: size.bytes, + isSizeEstimate: size.isEstimate + ) + } + + private func measuredSize(of url: URL) -> (bytes: Int64, isEstimate: Bool) { + let keys: Set = [ + .isDirectoryKey, + .fileAllocatedSizeKey, + .totalFileAllocatedSizeKey + ] + let values = try? url.resourceValues(forKeys: keys) + guard values?.isDirectory == true else { + let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0 + return (Int64(size), values == nil) + } + + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles], + errorHandler: { _, _ in true } + ) else { + return (0, true) + } + + var bytes: Int64 = 0 + var visited = 0 + var isEstimate = false + for case let childURL as URL in enumerator { + visited += 1 + if visited > maxDescendantsPerItem { + isEstimate = true + break + } + + guard let childValues = try? childURL.resourceValues(forKeys: keys) else { + isEstimate = true + continue + } + if childValues.isDirectory != true { + bytes += Int64(childValues.totalFileAllocatedSize ?? childValues.fileAllocatedSize ?? 0) + } + } + return (bytes, isEstimate) + } + + private func isSymbolicLink(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } + + fileprivate static func bundleMetadata(at applicationURL: URL) -> (bundleIdentifier: String, name: String?)? { + let infoURL = applicationURL.appending(path: "Contents/Info.plist") + guard let data = try? Data(contentsOf: infoURL), + let propertyList = try? PropertyListSerialization.propertyList(from: data, format: nil), + let info = propertyList as? [String: Any], + let bundleIdentifier = info["CFBundleIdentifier"] as? String else { + return nil + } + + let displayName = info["CFBundleDisplayName"] as? String + let bundleName = info["CFBundleName"] as? String + return (bundleIdentifier, displayName ?? bundleName) + } + + fileprivate static func leftoverURL( + for kind: ApplicationLeftoverKind, + bundleIdentifier: String, + homeDirectory: URL + ) -> URL { + switch kind { + case .cache: + homeDirectory.appending(path: "Library/Caches/\(bundleIdentifier)", directoryHint: .isDirectory) + case .preferences: + homeDirectory.appending(path: "Library/Preferences/\(bundleIdentifier).plist") + case .savedApplicationState: + homeDirectory.appending(path: "Library/Saved Application State/\(bundleIdentifier).savedState", directoryHint: .isDirectory) + case .logs: + homeDirectory.appending(path: "Library/Logs/\(bundleIdentifier)", directoryHint: .isDirectory) + } + } + + fileprivate static func isSafeBundleIdentifier(_ value: String) -> Bool { + guard !value.isEmpty, !value.hasPrefix("."), !value.hasSuffix(".") else { + return false + } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + return value.unicodeScalars.allSatisfy { allowed.contains($0) } + } + + fileprivate static func canonicalPath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + } +} + +public enum ApplicationRemovalTargetKind: Equatable, Sendable { + case application + case leftover(ApplicationLeftoverKind) +} + +public struct ApplicationRemovalPlanItem: Identifiable, Equatable, Sendable { + public let id: String + public let kind: ApplicationRemovalTargetKind + public let path: String + public let sizeBytes: Int64 + + public init( + kind: ApplicationRemovalTargetKind, + path: String, + sizeBytes: Int64 + ) { + self.id = path + self.kind = kind + self.path = path + self.sizeBytes = sizeBytes + } +} + +public enum ApplicationRemovalRejectionReason: String, Sendable { + case missing + case invalidApplicationPath + case forbiddenBundleIdentifier + case symbolicLink + case invalidLeftover +} + +public struct ApplicationRemovalRejectedItem: Identifiable, Equatable, Sendable { + public let id: String + public let path: String + public let reason: ApplicationRemovalRejectionReason + public let message: String + + public init(path: String, reason: ApplicationRemovalRejectionReason, message: String) { + self.id = path + self.path = path + self.reason = reason + self.message = message + } +} + +public struct ApplicationRemovalPlan: Equatable, Sendable { + public let createdAt: Date + public let application: InstalledApplication + public let applicationItem: ApplicationRemovalPlanItem? + public let leftoverItems: [ApplicationRemovalPlanItem] + public let rejectedItems: [ApplicationRemovalRejectedItem] + + public init( + createdAt: Date, + application: InstalledApplication, + applicationItem: ApplicationRemovalPlanItem?, + leftoverItems: [ApplicationRemovalPlanItem], + rejectedItems: [ApplicationRemovalRejectedItem] + ) { + self.createdAt = createdAt + self.application = application + self.applicationItem = applicationItem + self.leftoverItems = leftoverItems + self.rejectedItems = rejectedItems + } +} + +public struct ApplicationRemovalPlanner { + private let fileManager: FileManager + private let applicationDirectories: [URL] + private let homeDirectory: URL + private let excludedBundleIdentifiers: Set + private let excludedApplicationPaths: Set + + public init( + fileManager: FileManager = .default, + applicationDirectories: [URL]? = nil, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + excludedBundleIdentifiers: Set = [], + excludedApplicationPaths: Set = [] + ) { + self.fileManager = fileManager + self.homeDirectory = homeDirectory + self.applicationDirectories = applicationDirectories ?? [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectory.appending(path: "Applications", directoryHint: .isDirectory) + ] + self.excludedBundleIdentifiers = excludedBundleIdentifiers + self.excludedApplicationPaths = Set(excludedApplicationPaths.map(InstalledApplicationScanner.canonicalPath)) + } + + public func plan( + for application: InstalledApplication, + selectedLeftoverIDs: Set + ) -> ApplicationRemovalPlan { + var rejectedItems: [ApplicationRemovalRejectedItem] = [] + let applicationURL = URL(fileURLWithPath: application.path) + + guard fileManager.fileExists(atPath: application.path) else { + return rejectedPlan( + application, + path: application.path, + reason: .missing, + message: "The application no longer exists." + ) + } + + if isSymbolicLink(applicationURL) { + return rejectedPlan( + application, + path: application.path, + reason: .symbolicLink, + message: "Symbolic links cannot be removed by the application uninstaller." + ) + } + + let canonicalApplicationPath = InstalledApplicationScanner.canonicalPath(application.path) + let allowedParentPaths = Set(applicationDirectories.map { + InstalledApplicationScanner.canonicalPath($0.path) + }) + guard applicationURL.pathExtension.lowercased() == "app", + allowedParentPaths.contains(URL(fileURLWithPath: canonicalApplicationPath).deletingLastPathComponent().path), + !excludedApplicationPaths.contains(canonicalApplicationPath) else { + return rejectedPlan( + application, + path: application.path, + reason: .invalidApplicationPath, + message: "The application is outside the allowed Applications folders." + ) + } + + guard let metadata = InstalledApplicationScanner.bundleMetadata(at: applicationURL), + metadata.bundleIdentifier == application.bundleIdentifier, + InstalledApplicationScanner.isSafeBundleIdentifier(metadata.bundleIdentifier), + !metadata.bundleIdentifier.hasPrefix("com.apple."), + !excludedBundleIdentifiers.contains(metadata.bundleIdentifier) else { + return rejectedPlan( + application, + path: application.path, + reason: .forbiddenBundleIdentifier, + message: "The application bundle identifier failed the safety check." + ) + } + + let applicationItem = ApplicationRemovalPlanItem( + kind: .application, + path: canonicalApplicationPath, + sizeBytes: application.sizeBytes + ) + var leftoverItems: [ApplicationRemovalPlanItem] = [] + let knownLeftoversByID = Dictionary( + uniqueKeysWithValues: application.leftovers.map { ($0.id, $0) } + ) + + for selectedID in selectedLeftoverIDs.sorted() { + guard let scannedLeftover = knownLeftoversByID[selectedID] else { + rejectedItems.append(ApplicationRemovalRejectedItem( + path: selectedID, + reason: .invalidLeftover, + message: "The selected leftover was not part of the application scan." + )) + continue + } + + let expectedURL = InstalledApplicationScanner.leftoverURL( + for: scannedLeftover.kind, + bundleIdentifier: metadata.bundleIdentifier, + homeDirectory: homeDirectory + ) + guard fileManager.fileExists(atPath: expectedURL.path) else { + rejectedItems.append(ApplicationRemovalRejectedItem( + path: scannedLeftover.path, + reason: .missing, + message: "The selected leftover no longer exists." + )) + continue + } + guard !isSymbolicLink(expectedURL) else { + rejectedItems.append(ApplicationRemovalRejectedItem( + path: scannedLeftover.path, + reason: .symbolicLink, + message: "Symbolic link leftovers require manual review." + )) + continue + } + + let expectedPath = InstalledApplicationScanner.canonicalPath(expectedURL.path) + guard expectedPath == InstalledApplicationScanner.canonicalPath(scannedLeftover.path) else { + rejectedItems.append(ApplicationRemovalRejectedItem( + path: scannedLeftover.path, + reason: .invalidLeftover, + message: "The selected leftover path failed the bundle identifier check." + )) + continue + } + + leftoverItems.append(ApplicationRemovalPlanItem( + kind: .leftover(scannedLeftover.kind), + path: expectedPath, + sizeBytes: scannedLeftover.sizeBytes + )) + } + + return ApplicationRemovalPlan( + createdAt: Date(), + application: application, + applicationItem: applicationItem, + leftoverItems: leftoverItems, + rejectedItems: rejectedItems + ) + } + + private func rejectedPlan( + _ application: InstalledApplication, + path: String, + reason: ApplicationRemovalRejectionReason, + message: String + ) -> ApplicationRemovalPlan { + ApplicationRemovalPlan( + createdAt: Date(), + application: application, + applicationItem: nil, + leftoverItems: [], + rejectedItems: [ApplicationRemovalRejectedItem(path: path, reason: reason, message: message)] + ) + } + + private func isSymbolicLink(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } +} + +public struct ApplicationRemovalMovedItem: Identifiable, Equatable, Sendable { + public let id: String + public let item: ApplicationRemovalPlanItem + public let trashedPath: String? + + public init(item: ApplicationRemovalPlanItem, trashedPath: String?) { + self.id = item.id + self.item = item + self.trashedPath = trashedPath + } +} + +public struct ApplicationRemovalFailedItem: Identifiable, Equatable, Sendable { + public let id: String + public let item: ApplicationRemovalPlanItem + public let message: String + + public init(item: ApplicationRemovalPlanItem, message: String) { + self.id = item.id + self.item = item + self.message = message + } +} + +public struct ApplicationRemovalReport: Equatable, Sendable { + public let completedAt: Date + public let movedItems: [ApplicationRemovalMovedItem] + public let failedItems: [ApplicationRemovalFailedItem] + public let rejectedItems: [ApplicationRemovalRejectedItem] + + public init( + completedAt: Date, + movedItems: [ApplicationRemovalMovedItem], + failedItems: [ApplicationRemovalFailedItem], + rejectedItems: [ApplicationRemovalRejectedItem] + ) { + self.completedAt = completedAt + self.movedItems = movedItems + self.failedItems = failedItems + self.rejectedItems = rejectedItems + } + + public var applicationMoved: Bool { + movedItems.contains { $0.item.kind == .application } + } + + public var totalMovedBytes: Int64 { + movedItems.reduce(0) { $0 + $1.item.sizeBytes } + } + + public var hasProblems: Bool { + !failedItems.isEmpty || !rejectedItems.isEmpty + } +} + +public struct ApplicationRemovalExecutor { + public typealias TrashHandler = (URL) throws -> URL? + + private let trashHandler: TrashHandler + + public init(fileManager: FileManager = .default, trashHandler: TrashHandler? = nil) { + if let trashHandler { + self.trashHandler = trashHandler + } else { + self.trashHandler = { url in + var resultingURL: NSURL? + try fileManager.trashItem(at: url, resultingItemURL: &resultingURL) + return resultingURL as URL? + } + } + } + + public func execute(plan: ApplicationRemovalPlan) -> ApplicationRemovalReport { + guard let applicationItem = plan.applicationItem else { + return ApplicationRemovalReport( + completedAt: Date(), + movedItems: [], + failedItems: [], + rejectedItems: plan.rejectedItems + ) + } + + var movedItems: [ApplicationRemovalMovedItem] = [] + var failedItems: [ApplicationRemovalFailedItem] = [] + + do { + let trashedURL = try trashHandler(URL(fileURLWithPath: applicationItem.path)) + movedItems.append(ApplicationRemovalMovedItem( + item: applicationItem, + trashedPath: trashedURL?.path + )) + } catch { + failedItems.append(ApplicationRemovalFailedItem( + item: applicationItem, + message: error.localizedDescription + )) + return ApplicationRemovalReport( + completedAt: Date(), + movedItems: movedItems, + failedItems: failedItems, + rejectedItems: plan.rejectedItems + ) + } + + for leftoverItem in plan.leftoverItems { + do { + let trashedURL = try trashHandler(URL(fileURLWithPath: leftoverItem.path)) + movedItems.append(ApplicationRemovalMovedItem( + item: leftoverItem, + trashedPath: trashedURL?.path + )) + } catch { + failedItems.append(ApplicationRemovalFailedItem( + item: leftoverItem, + message: error.localizedDescription + )) + } + } + + return ApplicationRemovalReport( + completedAt: Date(), + movedItems: movedItems, + failedItems: failedItems, + rejectedItems: plan.rejectedItems + ) + } +} diff --git a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift index fb34c95..ddac86a 100644 --- a/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift +++ b/CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift @@ -319,6 +319,209 @@ final class CleanMacCoreTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: trashedFolder.path)) } + func testApplicationScannerFindsOnlyThirdPartyAppsAndExactLeftovers() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let sharedApplications = root.appending(path: "Applications", directoryHint: .isDirectory) + let userApplications = home.appending(path: "Applications", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: sharedApplications, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: userApplications, withIntermediateDirectories: true) + + let thirdPartyApp = try makeFakeApplication( + named: "Example", + bundleIdentifier: "com.example.editor", + in: sharedApplications + ) + _ = try makeFakeApplication( + named: "AppleUtility", + bundleIdentifier: "com.apple.utility", + in: sharedApplications + ) + _ = try makeFakeApplication( + named: "CleanMac", + bundleIdentifier: "com.codex.cleanmac", + in: userApplications + ) + + let cache = home.appending(path: "Library/Caches/com.example.editor", directoryHint: .isDirectory) + let preferences = home.appending(path: "Library/Preferences/com.example.editor.plist") + 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] { + 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) + + let alias = sharedApplications.appending(path: "Example Alias.app") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: thirdPartyApp) + + let report = InstalledApplicationScanner( + applicationDirectories: [sharedApplications, userApplications], + homeDirectory: home, + excludedBundleIdentifiers: ["com.codex.cleanmac"], + maxDescendantsPerItem: 100 + ).scan() + + XCTAssertEqual(report.applications.map(\.name), ["Example"]) + XCTAssertEqual(report.applications.first?.bundleIdentifier, "com.example.editor") + XCTAssertEqual( + Set(report.applications.first?.leftovers.map(\.kind) ?? []), + Set(ApplicationLeftoverKind.allCases) + ) + XCTAssertFalse(report.applications.first?.leftovers.contains { $0.path == applicationSupport.path } ?? true) + XCTAssertTrue(FileManager.default.fileExists(atPath: thirdPartyApp.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: applicationSupport.path)) + } + + func testApplicationRemovalMovesAppFirstThenExactLeftovers() 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) + let trash = root.appending(path: "Trash", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: applications, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: trash, 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 preferences = home.appending(path: "Library/Preferences/com.example.editor.plist") + 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")) + } + try FileManager.default.createDirectory(at: preferences.deletingLastPathComponent(), withIntermediateDirectories: true) + try writeBytes(count: 8, to: preferences) + + 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 movedPaths: [String] = [] + let report = ApplicationRemovalExecutor { url in + movedPaths.append(url.path) + let destination = trash.appending(path: "\(movedPaths.count)-\(url.lastPathComponent)") + try FileManager.default.moveItem(at: url, to: destination) + return destination + }.execute(plan: plan) + + XCTAssertTrue(report.applicationMoved) + XCTAssertEqual(report.failedItems.count, 0) + XCTAssertEqual(report.rejectedItems.count, 0) + XCTAssertEqual(movedPaths.first, appURL.path) + XCTAssertEqual(Set(movedPaths.dropFirst()), Set([cache.path, preferences.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)) + } + + func testApplicationRemovalDoesNotTouchLeftoversWhenAppMoveFails() 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) + try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true) + try writeBytes(count: 8, to: cache.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 attemptedPaths: [String] = [] + let report = ApplicationRemovalExecutor { url in + attemptedPaths.append(url.path) + throw NSError(domain: "CleanMacCoreTests", code: 1) + }.execute(plan: plan) + + XCTAssertFalse(report.applicationMoved) + XCTAssertEqual(report.failedItems.count, 1) + XCTAssertEqual(attemptedPaths, [appURL.path]) + XCTAssertTrue(FileManager.default.fileExists(atPath: appURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cache.path)) + } + + func testApplicationRemovalPlannerRejectsOutsideAppAndUnknownLeftover() throws { + let root = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appending(path: "Home", directoryHint: .isDirectory) + let allowedApplications = root.appending(path: "Applications", directoryHint: .isDirectory) + let outsideApplications = root.appending(path: "Other", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: allowedApplications, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outsideApplications, withIntermediateDirectories: true) + _ = try makeFakeApplication( + named: "Outside", + bundleIdentifier: "com.example.outside", + in: outsideApplications + ) + + let outsideApplication = try XCTUnwrap(InstalledApplicationScanner( + applicationDirectories: [outsideApplications], + homeDirectory: home + ).scan().applications.first) + let outsidePlan = ApplicationRemovalPlanner( + applicationDirectories: [allowedApplications], + homeDirectory: home + ).plan(for: outsideApplication, selectedLeftoverIDs: []) + + XCTAssertNil(outsidePlan.applicationItem) + XCTAssertEqual(outsidePlan.rejectedItems.first?.reason, .invalidApplicationPath) + + _ = try makeFakeApplication( + named: "Allowed", + bundleIdentifier: "com.example.allowed", + in: allowedApplications + ) + let allowedApplication = try XCTUnwrap(InstalledApplicationScanner( + applicationDirectories: [allowedApplications], + homeDirectory: home + ).scan().applications.first) + let forgedPath = home.appending(path: "Library/Application Support/com.example.allowed").path + let forgedPlan = ApplicationRemovalPlanner( + applicationDirectories: [allowedApplications], + homeDirectory: home + ).plan(for: allowedApplication, selectedLeftoverIDs: [forgedPath]) + + XCTAssertNotNil(forgedPlan.applicationItem) + XCTAssertTrue(forgedPlan.leftoverItems.isEmpty) + XCTAssertEqual(forgedPlan.rejectedItems.first?.reason, .invalidLeftover) + } + private func makeTemporaryRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appending(path: "CleanMacCoreTests-\(UUID().uuidString)", directoryHint: .isDirectory) @@ -330,6 +533,34 @@ final class CleanMacCoreTests: XCTestCase { try Data(repeating: 1, count: count).write(to: url) } + private func makeFakeApplication( + named name: String, + bundleIdentifier: String, + in directory: URL + ) throws -> URL { + let applicationURL = directory.appending(path: "\(name).app", directoryHint: .isDirectory) + let contentsURL = applicationURL.appending(path: "Contents", directoryHint: .isDirectory) + let executableURL = contentsURL.appending(path: "MacOS/\(name)") + try FileManager.default.createDirectory( + at: executableURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let info: [String: Any] = [ + "CFBundleIdentifier": bundleIdentifier, + "CFBundleDisplayName": name, + "CFBundleName": name + ] + let infoData = try PropertyListSerialization.data( + fromPropertyList: info, + format: .xml, + options: 0 + ) + try infoData.write(to: contentsURL.appending(path: "Info.plist")) + try writeBytes(count: 16, to: executableURL) + return applicationURL + } + private func setModificationDate(_ date: Date, for url: URL) throws { try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) } diff --git a/contract.md b/contract.md index 57ae350..adc1ae7 100644 --- a/contract.md +++ b/contract.md @@ -2,70 +2,77 @@ ## Task -- ID: TASK-027 -- Title: Sidebar click and keyboard focus polish +- ID: TASK-032 +- Title: Multi-select application removal - Mode: continue ## Planner Notes -- Why this task now: the user selected adding click animation and keyboard focus styling after the sidebar hover polish. -- Expected value: the sidebar feels responsive for mouse users and remains clear for keyboard navigation. -- Main risk: focus styling can compete with the selected accent state or click animation can feel jumpy. -- UX constraint: keep motion subtle, preserve readability, and respect Reduce Motion. +- Why this task now: the user explicitly requested checkbox selection for removing several applications from the Applications screen. +- Expected value: the user can build one reviewed removal set instead of confirming every app separately. +- Main risk: mixing detail selection with removal selection, losing per-app leftover choices, or touching leftovers after an individual app move fails. +- UX constraint: use native macOS checkbox controls; keep the last interacted app visible in the detail panel; preserve mandatory confirmation and Trash-only removal. ## Builder Scope - Allowed files: - - `CleanMac/Views/SidebarView.swift` + - `CleanMac/Views/ApplicationsView.swift` + - `CleanMac/en.lproj/Localizable.strings` + - `CleanMac/ru.lproj/Localizable.strings` - `project-analysis.md` - `roadmap.md` - `contract.md` - `progress.md` + - `verification.md` - Allowed commands: - - `./script/build_and_run.sh --verify` + - `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings` - `swift test --package-path CleanMacCore` - - `xcodebuild -project CleanMac.xcodeproj -scheme CleanMac -configuration Debug -derivedDataPath build/XcodeData build CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=""` + - `./script/build_and_run.sh --verify` - `git diff --check` - - visual screenshot commands if useful + - non-destructive UI inspection and screenshots - Out of scope: - - automatic cleanup or deletion; - - scanner or cleanup behavior; - - settings, scheduling, or notification behavior; - - release packaging changes; - - main window redesign. + - permanent deletion, Empty Trash, privilege escalation, or forced process termination; + - changing the application scanner or path allowlists; + - selecting leftovers automatically; + - removing a real installed application during verification; + - unrelated cleanup, permissions, packaging, or release changes. - Dependencies allowed: no -- Destructive actions allowed: no +- Destructive actions allowed: yes, only after the existing dedicated in-app confirmation and only for explicitly checked applications. ## Evaluator Checklist - Done criteria: - - Sidebar rows show a subtle press/click animation. - - Keyboard-focused sidebar rows show a visible focus outline or glow. - - Selected section remains clear and does not lose focus readability. - - Russian labels still fit at the current sidebar width. - - Reduce Motion avoids animated movement and press scaling. - - Navigation selection behavior is unchanged. + - Every application row has a native checkbox with a visible checked state. + - Multiple applications can remain checked while one app is shown in the detail panel. + - Exact leftover choices are stored separately for each checked application. + - The summary and destructive button show the selected application count and total reviewed size. + - Confirmation lists the number of apps, selected leftovers, and total size. + - Each app is independently replanned and moved before its leftovers; one app failure cannot touch that app's leftovers or block safe reporting for the remaining checked apps. + - Successfully moved apps leave the list; failed apps remain selected for review. + - Refresh preserves only selections that still exist. - Required verification: + - `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings` - `swift test --package-path CleanMacCore` - - `xcodebuild -project CleanMac.xcodeproj -scheme CleanMac -configuration Debug -derivedDataPath build/XcodeData build CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=""` - - `git diff --check` - `./script/build_and_run.sh --verify` + - `git diff --check` - Manual checks: - - Sidebar renders without clipping, click feedback is visible, and keyboard focus styling is visible. + - Check at least two applications and confirm both checkboxes remain selected. + - Switch details and verify per-app leftover choices do not leak to another app. + - Open the batch confirmation and cancel it without removing anything. - Evidence to collect: - Build/test command exit status. - - Visual screenshot path if captured. - - File list touched. + - Accessibility state showing multiple checked rows and batch confirmation. + - UI screenshot path if captured. ## Restart Signals Restart or shrink the task if: -- the custom row breaks sidebar selection; -- press/focus states cause layout jumps; -- the fix requires unrelated navigation or window restructuring. +- SwiftUI checkbox composition breaks row accessibility or detail selection; +- batch execution would require weakening the existing single-app planner/executor checks; +- UI verification would require accepting a removal confirmation. ## Result - Status: complete -- Verification result: Passed. Debug Xcode build, `./script/build_and_run.sh --verify`, SwiftPM tests, `git diff --check`, visual screenshot `/tmp/cleanmac-sidebar-keyboard-focus.png`, and local release packaging all pass. -- Notes: Sidebar rows now have subtle click press feedback and visible keyboard focus styling. Reduce Motion disables press scaling and movement. Scan, cleanup, scheduling, notification, language, and theme behavior were not changed. +- Verification result: passed. All 15 core tests, localization lint, `git diff --check`, Debug build, and `./script/build_and_run.sh --verify` pass. Live accessibility review showed two independently checked apps, isolated per-app leftover state, and the correct batch confirmation. +- Notes: screenshot saved at `/tmp/cleanmac-task32-multiselect.jpg`. The batch confirmation was cancelled; no installed application or real leftover was moved. diff --git a/progress.md b/progress.md index 7a71351..cd0d7b5 100644 --- a/progress.md +++ b/progress.md @@ -220,3 +220,53 @@ Append-only history. Do not erase previous entries. - Next step: Decide whether to add a broader glass sidebar style or leave the sidebar interaction polish restrained. - Bottleneck: product decision. - Handoff: This is UI-only. Scan, cleanup, scheduling, notification, language, and theme behavior were not changed. + +## 2026-07-11 - TASK-028 - Enforce Safe Mode during cleanup review + +- What changed: Connected the existing Safe Mode preference to Results selection and cleanup execution. Review-risk items remain visible but show a disabled checkbox and lock indicator while Safe Mode is enabled; bulk selection and totals only include allowed items; enabling Safe Mode removes stale review selections; cleanup filters risk again before planning or execution. +- Files touched: `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`. +- Checks run: `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings`; `git diff --check`; `swift test --package-path CleanMacCore` (11/11); Debug `xcodebuild`; `./script/build_and_run.sh --verify`; manual Safe Mode ON/OFF scan-results review; screenshot `/tmp/cleanmac-safe-mode-review.png`; independent diff review. +- Result: Passed. A review-risk Derived Data result is locked and unselected with Safe Mode on, becomes selectable when Safe Mode is off, and is removed from selection immediately when Safe Mode is re-enabled. No cleanup action was triggered. +- Next step: TASK-029 - show specific unavailable scan areas and permission guidance instead of only an issue count. +- Bottleneck: none. +- Handoff: Safe Mode is left enabled after verification. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-11 - TASK-030 - Real Finder Automation permission + +- What changed: Replaced the static Automation placeholder with a live Finder Apple Events permission state and explicit request/settings actions. Added off-main-thread preflight/request handling, Finder reveal through Apple Events only when granted, the existing NSWorkspace fallback, localized privacy text, the Automation entitlement, hardened Debug/Release signing, and strict verification of the archived release app. +- Files touched: `CleanMac/Support/CleanMacAutomationService.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/PermissionsView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/CleanMac.entitlements`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `CleanMac/en.lproj/InfoPlist.strings`, `CleanMac/ru.lproj/InfoPlist.strings`, `CleanMac.xcodeproj/project.pbxproj`, `script/build_and_run.sh`, `script/package_release.sh`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: plist/localization lint; shell syntax checks; `git diff --check`; `swift test --package-path CleanMacCore` (11/11); `./script/build_and_run.sh --verify`; Debug runtime/entitlement/Info.plist inspection; live Permissions UI review through accessibility; `./script/package_release.sh`; SHA-256 verification; fresh ZIP extraction followed by strict codesign, hardened-runtime, entitlement, localized InfoPlist, and usage-description inspection; independent final diff review. +- Result: Passed. Permissions opens without prompting, reports Finder Automation as `Не запрошен`, and exposes the active `Запросить доступ` button. No native consent button was pressed and no macOS privacy setting was changed during verification. +- Next step: The user can press `Запросить доступ` and approve CleanMac in the native macOS dialog, then continue with TASK-029. +- Bottleneck: none in code; the final consent decision belongs to the user. +- Handoff: The release ZIP is ad-hoc signed for local validation because no Developer ID identity is configured. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-11 - TASK-029 - Explain unavailable scan areas + +- What changed: Replaced the generic Results issue banner with an actionable availability panel. It groups duplicate failures by cleanup category, shows localized area names and exact paths, distinguishes missing optional folders from read failures, confirms that other areas were scanned, and offers an in-app Permissions action only when reading actually failed. +- Files touched: `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`. +- Checks run: `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings`; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; `swift test --package-path CleanMacCore` (11/11); `git diff --check`; live read-only scan and accessibility review; screenshot `/tmp/cleanmac-task29-unavailable.png`; in-app Permissions navigation check. +- Result: Passed. A real Safari cache read failure rendered as `Кэши браузеров — Не удалось прочитать` with `/Users/admin/Library/Caches/com.apple.Safari`; `Проверить доступы` opened the CleanMac Permissions page without opening System Settings or triggering another permission request. +- Next step: choose between Developer ID signing, persistent cleanup history, or launch-at-login support. +- Bottleneck: Developer ID distribution still requires Apple credentials; the other product tasks have no current blocker. +- Handoff: No cleanup action was triggered and no files were moved. The existing selected scan areas and Safe Mode behavior were preserved. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-11 - TASK-031 - Safe application uninstaller + +- What changed: Added a separate Applications section that scans direct third-party `.app` bundles in `/Applications` and `~/Applications`, excludes Apple apps, CleanMac, symlinks, and nested bundles, and shows only exact bundle-ID Caches, Preferences, Saved Application State, and Logs leftovers. Leftovers are optional and unchecked by default. Removal has a dedicated confirmation, revalidates paths, moves the app first, stops before leftovers if that move fails, and uses Trash rather than permanent deletion. +- Files touched: `CleanMacCore/Sources/CleanMacCore/ApplicationUninstaller.swift`, `CleanMacCore/Tests/CleanMacCoreTests/CleanMacCoreTests.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ApplicationsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (15/15); `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings`; `git diff --check`; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live read-only Russian UI review; mandatory confirmation review followed by Cancel; screenshot `/tmp/cleanmac-task31-applications.jpg`. +- Result: Passed. The live screen listed 39 third-party applications, showed exact optional leftovers unselected, fit the standard window, and displayed the mandatory app/leftover/size confirmation. Unit tests prove Apple/self/symlink exclusions, app-first Trash order, no leftover attempt after app-move failure, and rejection of outside/forged paths. +- Next step: Add a read-only large-files review or deeper developer-storage cleanup preview. +- Bottleneck: Root-owned third-party apps can require administrator privileges; this version fails safely and does not install a privileged helper. +- Handoff: No installed application or real leftover was moved during verification. The confirmation dialog was cancelled. The known stale CoreSimulator warning remains non-blocking for macOS builds. + +## 2026-07-11 - TASK-032 - Multi-select application removal + +- What changed: Added native checkbox controls beside every application, separated checkbox selection from detail-row navigation, stored exact leftover choices per application, added a selected app/size summary, and changed the destructive action and confirmation to cover the whole reviewed set. Batch execution sequentially reuses the existing single-app planner and executor, so each app is revalidated and moved before its own leftovers; successful apps leave the list while failures remain selected for review. +- Files touched: `CleanMac/Views/ApplicationsView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `project-analysis.md`, `roadmap.md`, `contract.md`, `progress.md`, `verification.md`. +- Checks run: `swift test --package-path CleanMacCore` (15/15); `plutil -lint CleanMac/en.lproj/Localizable.strings CleanMac/ru.lproj/Localizable.strings`; `git diff --check`; Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live accessibility and visual review; screenshot `/tmp/cleanmac-task32-multiselect.jpg`. +- Result: Passed. AdGuard Mini and Antigravity stayed checked simultaneously, switching details did not change either checkbox, Antigravity's selected Preferences leftover remained isolated when AdGuard details were open, and the confirmation reported 2 apps, 1 leftover, and 816.2 MB. +- Next step: Add a compact Select visible/Clear selection action if bulk selection becomes frequent, or continue with large-file review. +- Bottleneck: none. +- Handoff: The confirmation was cancelled and no real app or leftover was moved. The known stale CoreSimulator warning remains non-blocking; a transient FinderInfo attribute on the built `.app` was cleared before the successful verification rerun. diff --git a/project-analysis.md b/project-analysis.md index 0ab5ca7..2407def 100644 --- a/project-analysis.md +++ b/project-analysis.md @@ -2,7 +2,7 @@ ## Purpose -CleanMac is a fresh macOS menu bar and windowed app shell for a custom system cleanup tool. The project was intentionally reset from the original upstream code so new cleanup behavior can be built safely. +CleanMac is a macOS menu bar and windowed system cleanup utility. The project was intentionally reset from the original upstream code so cleanup behavior can be built safely. ## Current Structure @@ -37,14 +37,17 @@ CleanMac is a fresh macOS menu bar and windowed app shell for a custom system cl - App icon, menu bar icon, Dashboard brand icon, status menu brand icon, design assets, and docs icon use the supplied detailed broom artwork from `Design/source-icon.png`. - Private GitHub repository and CI were configured. - Local `dist/` packaging exists and is ignored by git. -- Main Dashboard/Scan/Results/Permissions/Settings window builds and launches. +- Main Dashboard/Scan/Results/Applications/Permissions/Settings window builds and launches. - Main content pages use the supplied light technology background while preserving the native macOS sidebar. - Sidebar navigation uses subtle modern hover, click, and keyboard focus feedback while preserving the selected accent row, and the footer has persistent RU/EN language and light/dark appearance controls; language still defaults from system preferences when no override exists. - Menu bar Open focuses the existing main window before creating a new one. - `CleanMacCore` has a read-only scanner for user caches, logs, temporary files, Trash, Downloads review, Xcode Derived Data, browser caches, Node/npm/Yarn/pnpm caches, SwiftPM cache, and downloaded installers. - 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. - Results UI now has compact category groups, risk-aware item review, a selected-item detail panel, and current-session Trash history with restore actions. - 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. @@ -52,13 +55,14 @@ CleanMac is a fresh macOS menu bar and windowed app shell for a custom system cl - Scan UI now binds to real scanner progress, including current area, percentage, found count, and measured size while scanning. - Downloads, logs, and temporary files use conservative smart rules to reduce noisy candidates from recent small downloads and likely active files. - Permissions UI checks live Full Disk Access status by probing protected metadata/readability and can refresh the result. +- Finder Automation now shows the live Apple Events consent state, requests access only from an explicit button, and uses the permission to reveal selected items while preserving the NSWorkspace fallback. - English and Russian app localizations are included; macOS selects the language from system preferences. - The selected language override applies through the app's localizer, and the selected appearance applies to both the main window and menu bar popover. - The menu bar popover shows current disk usage, scan-in-progress state, last scan source/time, and last scan result summary with larger readable typography and rounded compact panels. - 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. - 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. - Private GitHub Release `v0.1.0` exists with unsigned zip and sha256 assets. -- Release packaging creates a clean unsigned/ad-hoc local zip plus sha256, strips Finder/resource-fork metadata before archiving, 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 local zip plus sha256, strips Finder/resource-fork metadata before archiving, strictly verifies a fresh ZIP extraction, and can optionally sign with Developer ID, enable hardened runtime, submit to Apple notary service, staple, and re-zip when credentials are configured. ## Unfinished Or Risky Parts @@ -69,6 +73,7 @@ CleanMac is a fresh macOS menu bar and windowed app shell for a custom system cl - Cleanup history is current-session only; persistent history across app launches is still future work. - Scheduled auto scan currently runs only while the CleanMac app process is running; launch-at-login or a privileged background agent is still future work. - Notification delivery depends on the macOS notification permission for CleanMac; if permission is denied, scheduled scans still complete silently. +- Root-owned or otherwise protected third-party apps may fail to move without administrator privileges; CleanMac reports the failure and does not escalate privileges or remove leftovers. - Local Xcode emits a CoreSimulator warning; it does not currently block macOS builds. ## Strengths @@ -89,5 +94,4 @@ CleanMac is a fresh macOS menu bar and windowed app shell for a custom system cl 1. Configure Apple Developer signing secrets and cut a signed/notarized release. 2. Persist cleanup history safely across app launches. 3. Add launch-at-login support so scheduled scans can happen after reboot/login without manually opening CleanMac. -4. Add deeper cleanup previews for large downloads and developer caches. -5. Add more permission-specific scanner hints when protected roots are unavailable. +4. Add read-only large-file review and deeper developer storage previews. diff --git a/roadmap.md b/roadmap.md index 0f6eddd..ffca24e 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,5 +1,33 @@ # Roadmap +- [x] ID: TASK-032 + Title: Multi-select application removal + Goal: Let the user check several applications and remove the reviewed set through one mandatory confirmation. + What to do: Add native row checkboxes, per-app leftover selections, a batch summary, and sequential use of the existing safe planner/executor. + Files: `CleanMac/Views/ApplicationsView.swift`, `CleanMac/*/Localizable.strings`, Loop docs + Definition of done: Multiple checked apps remain selected; per-app leftovers stay isolated; batch confirmation shows counts and size; each app still moves before its leftovers; no real app is removed during verification. + Verification: `swift test --package-path CleanMacCore`; `./script/build_and_run.sh --verify`; localization lint; non-destructive UI review + Priority: high + Impact: high + Risk: medium + Effort: medium + Confidence: high + Score: high impact / medium risk / medium + +- [x] ID: TASK-031 + Title: Safe application uninstaller + Goal: Let the user review and move a third-party app plus exact bundle-ID leftovers to Trash. + What to do: Add an isolated app scanner/removal policy in `CleanMacCore`, a separate Applications screen, mandatory confirmation, and temporary-fixture tests. + Files: `CleanMacCore/**`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ApplicationsView.swift`, `CleanMac/*/Localizable.strings`, Loop docs + Definition of done: Only direct third-party apps in `/Applications` and `~/Applications` are listed; only exact Caches/Preferences/Saved State/Logs leftovers are optional; the app is moved first; failures cannot delete leftovers; no real app is removed during verification. + Verification: `swift test --package-path CleanMacCore`; `./script/build_and_run.sh --verify`; localization lint; read-only visual review + Priority: high + Impact: high + Risk: high + Effort: medium + Confidence: high + Score: high impact / high risk / medium + - [x] ID: TASK-001 Title: Main window UI and launch lifecycle Goal: Make CleanMac open as a normal windowed app on launch while staying available from the menu bar after the window closes. @@ -377,3 +405,45 @@ Effort: small Confidence: high Score: medium impact / low risk / small + +- [x] ID: TASK-028 + Title: Enforce Safe Mode during cleanup review + Goal: Make the enabled-by-default Safe Mode actually prevent review-risk items from being selected or moved to Trash. + What to do: Pass Safe Mode into Results, lock review-risk selection while it is enabled, clear stale review selections when the preference turns on, and filter selected items again before cleanup execution. + Files: `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/*/Localizable.strings`, Loop docs + Definition of done: Safe Mode keeps review-risk items visible for inspection but unselectable; selection summaries and bulk selection include only allowed items; switching Safe Mode on removes stale review selections; cleanup execution rejects review-risk items even if stale selection state reaches it; disabling Safe Mode preserves the existing confirmation and Trash-only flow. + Verification: `swift test --package-path CleanMacCore`; Debug Xcode build; localization lint; `git diff --check`; `./script/build_and_run.sh --verify`; manual safe-mode ON/OFF review. + Priority: high + Impact: high + Risk: low + Effort: small + Confidence: high + Score: high impact / low risk / small + +- [x] ID: TASK-029 + Title: Explain unavailable scan areas + Goal: Replace the generic scan issue count with actionable details about which cleanup areas could not be checked. + What to do: Present unavailable category names and paths from the existing scan report, then guide the user to Permissions when access is the likely cause without changing scanner or cleanup behavior. + Files: `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/*/Localizable.strings`, Loop docs + Definition of done: Results identifies unavailable scan areas and gives a clear next action; successful categories and cleanup safety behavior remain unchanged. + Verification: `swift test --package-path CleanMacCore`; Debug Xcode build; localization lint; `git diff --check`; `./script/build_and_run.sh --verify`; manual unavailable-area review. + Priority: high + Impact: medium + Risk: low + Effort: small + Confidence: high + Score: medium impact / low risk / small + +- [x] ID: TASK-030 + Title: Real Finder Automation permission + Goal: Replace the static Automation placeholder with a real macOS Apple Events permission for revealing selected items in Finder. + What to do: Check Finder Automation without prompting, request access only from an explicit button, show granted/not-requested/denied/unavailable states, use Apple Events for Finder reveal with an NSWorkspace fallback, and preserve the entitlement through Debug and Release signing. + Files: `CleanMac/Support/CleanMacAutomationService.swift`, `CleanMac/Views/PermissionsView.swift`, `CleanMac/Views/ResultsView.swift`, `CleanMac/Models/CleanMacModels.swift`, `CleanMac/*/Localizable.strings`, `CleanMac/*/InfoPlist.strings`, `CleanMac/CleanMac.entitlements`, `CleanMac.xcodeproj/project.pbxproj`, `script/**`, Loop docs + Definition of done: Opening Permissions never prompts automatically; the Automation row shows the live Finder status; Request Access invokes the native macOS consent flow off the main thread; denied access links to Automation settings; Finder reveal uses Apple Events only when granted and still works through the existing fallback otherwise; built and packaged apps contain the usage description and Automation entitlement. + Verification: localization and plist lint; `swift test --package-path CleanMacCore`; Debug Xcode build; `./script/build_and_run.sh --verify`; `./script/package_release.sh`; Info.plist and codesign entitlement inspection; manual Permissions UI review without triggering cleanup. + Priority: high + Impact: medium + Risk: medium + Effort: medium + Confidence: high + Score: medium impact / medium risk / medium diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 9e8fb58..3003ee0 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -12,6 +12,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DATA_DIR="$ROOT_DIR/build/XcodeData" 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" cd "$ROOT_DIR" @@ -23,9 +24,22 @@ xcodebuild \ -configuration "$CONFIGURATION" \ -derivedDataPath "$BUILD_DATA_DIR" \ build \ + ENABLE_DEBUG_DYLIB=NO \ 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 \ + --force \ + --options runtime \ + --entitlements "$ENTITLEMENTS_PATH" \ + --sign - \ + "$APP_BUNDLE" +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" + open_app() { /usr/bin/open -n "$APP_BUNDLE" } diff --git a/script/package_release.sh b/script/package_release.sh index 119478c..094711a 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -11,6 +11,7 @@ BUILD_DATA_DIR="$ROOT_DIR/build/XcodeData" DIST_DIR="$ROOT_DIR/dist" BUILT_APP="$BUILD_DATA_DIR/Build/Products/$CONFIGURATION/$APP_NAME.app" DIST_APP="$DIST_DIR/$APP_NAME.app" +ENTITLEMENTS_PATH="$ROOT_DIR/CleanMac/CleanMac.entitlements" SIGN_IDENTITY="${CLEANMAC_SIGN_IDENTITY:-}" NOTARIZE="${CLEANMAC_NOTARIZE:-0}" NOTARY_PROFILE="${CLEANMAC_NOTARY_PROFILE:-}" @@ -58,6 +59,16 @@ create_zip() { rm -rf "$zip_root" } +verify_zip() ( + local zip_path="$1" + local verify_root + verify_root="$(mktemp -d "${TMPDIR:-/tmp}/cleanmac-release-verify.XXXXXX")" + trap 'rm -rf "$verify_root"' EXIT + + ditto -x -k "$zip_path" "$verify_root" + codesign --verify --deep --strict --verbose=2 "$verify_root/$APP_NAME.app" +) + build_notary_args() { if [[ -n "$NOTARY_PROFILE" ]]; then NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE") @@ -102,11 +113,13 @@ sanitize_app_bundle "$DIST_APP" if [[ -n "$SIGN_IDENTITY" ]]; then echo "Signing $DIST_APP with: $SIGN_IDENTITY" codesign --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" "$DIST_APP" + codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PATH" --sign "$SIGN_IDENTITY" "$DIST_APP" sanitize_app_bundle "$DIST_APP" codesign --verify --deep --strict --verbose=2 "$DIST_APP" else echo "No CLEANMAC_SIGN_IDENTITY configured; applying ad-hoc signature for local validation." - codesign --force --deep --sign - "$DIST_APP" + codesign --force --deep --options runtime --sign - "$DIST_APP" + codesign --force --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign - "$DIST_APP" sanitize_app_bundle "$DIST_APP" codesign --verify --deep --strict --verbose=2 "$DIST_APP" fi @@ -132,6 +145,12 @@ if [[ "$NOTARIZE" == "1" || "$NOTARIZE" == "true" ]]; then create_zip "$ZIP_PATH" fi +# Reading the app while creating the archive can make File Provider attach +# Finder metadata again. Remove it on a best-effort basis; verify the actual +# distributable ZIP from a fresh extraction below. +sanitize_app_bundle "$DIST_APP" +verify_zip "$ZIP_PATH" + shasum -a 256 "$ZIP_PATH" > "$ZIP_PATH.sha256" echo "Created:" diff --git a/trace.md b/trace.md index 2513094..3b9b3f6 100644 --- a/trace.md +++ b/trace.md @@ -22,3 +22,10 @@ Append-only trace of failures, restarts, and judgment divergences. - Cause: the test used a 64-byte "large download" threshold, but APFS allocated size for tiny files is several KB. - Fix: raised the test threshold and fixture size to model allocated-size behavior more realistically. - Status: resolved; `swift test --package-path CleanMacCore` passes. + +## 2026-07-11 - TASK-030 - Debug Automation signing + +- Symptom: the first ad-hoc Debug signing pass rejected Finder metadata; after sanitizing the bundle, the app still terminated at launch because hardened runtime blocked `CleanMac.debug.dylib` for having no shared Team ID with the ad-hoc main executable. +- Cause: Xcode's Debug product uses separate preview/debug dynamic libraries, while ad-hoc signatures have no stable Team ID for hardened library validation. The raw build bundle also retained extended attributes that codesign refuses. +- Fix: sanitize only the generated bundle, build Debug with `ENABLE_DEBUG_DYLIB=NO` so it has one executable, then sign the app ad-hoc with hardened runtime and the Automation entitlement. Release remains hardened and is signed in two passes so only the outer app receives the Automation entitlement. Because File Provider can reattach Finder metadata to `dist/CleanMac.app`, packaging now strictly verifies a fresh extraction of the metadata-free ZIP. +- Status: resolved; `./script/build_and_run.sh --verify` launches successfully with `adhoc,runtime`, and the extracted Release ZIP passes strict signature, usage-description, and entitlement inspection. diff --git a/verification.md b/verification.md index ae46b55..508c1f7 100644 --- a/verification.md +++ b/verification.md @@ -47,5 +47,7 @@ | --- | --- | --- | --- | --- | | macOS app launch | `./script/build_and_run.sh --verify` | UI, app lifecycle, assets, or project changes | Build succeeds and `pgrep -x CleanMac` finds the app | Run the xcodebuild command from this file and inspect launch logs | | Core package | `swift test --package-path CleanMacCore` | Core model, scanner, or package changes | All tests pass | Run `cd CleanMacCore && swift test` | +| 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 | | 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 |