diff --git a/MacOSCleaner/Domains/Cleanup/SystemMaintenanceService.swift b/MacOSCleaner/Domains/Cleanup/SystemMaintenanceService.swift new file mode 100644 index 0000000..199cb11 --- /dev/null +++ b/MacOSCleaner/Domains/Cleanup/SystemMaintenanceService.swift @@ -0,0 +1,117 @@ +// Copyright (C) 2026 AlexTkDev +// Licensed under GNU General Public License v3.0 (GPLv3) + +import Foundation +import LocalAuthentication +import os.log + +private extension Logger { + static let maintenance = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "SystemMaintenance") +} + +@MainActor +@Observable +public final class SystemMaintenanceService { + public private(set) var isTouchIDHardwareAvailable: Bool = false + public private(set) var isTouchIDForSudoEnabled: Bool = false + public private(set) var isReindexingSpotlight: Bool = false + public private(set) var spotlightStatusMessage: String? = nil + public private(set) var errorMessage: String? = nil + + private let pamSudoLocalPath = "/private/etc/pam.d/sudo_local" + private let pamSudoLocalTemplatePath = "/private/etc/pam.d/sudo_local.template" + + public init() { + refreshTouchIDStatus() + } + + public func refreshTouchIDStatus() { + let context = LAContext() + var error: NSError? + self.isTouchIDHardwareAvailable = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + self.isTouchIDForSudoEnabled = checkTouchIDForSudo() + } + + private func checkTouchIDForSudo() -> Bool { + guard FileManager.default.fileExists(atPath: pamSudoLocalPath) else { + return false + } + guard let content = try? String(contentsOfFile: pamSudoLocalPath, encoding: .utf8) else { + return false + } + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.hasPrefix("#") && trimmed.contains("pam_tid.so") { + return true + } + } + return false + } + + public func setTouchIDForSudo(enabled: Bool) async throws { + errorMessage = nil + + let currentContent = (try? String(contentsOfFile: pamSudoLocalPath, encoding: .utf8)) + ?? (try? String(contentsOfFile: pamSudoLocalTemplatePath, encoding: .utf8)) + + let content: String + if enabled { + if let current = currentContent { + if current.contains("pam_tid.so") { + content = current.replacingOccurrences( + of: #"^[#\s]*(auth\s+sufficient\s+pam_tid\.so)"#, + with: "auth sufficient pam_tid.so", + options: .regularExpression + ) + } else { + content = "auth sufficient pam_tid.so\n" + current + } + } else { + content = "# sudo_local: local PAM configuration for sudo\nauth sufficient pam_tid.so\n" + } + } else { + if let current = currentContent { + content = current.replacingOccurrences( + of: #"^(auth\s+sufficient\s+pam_tid\.so)"#, + with: "#$1", + options: .regularExpression + ) + } else { + content = "# sudo_local: local PAM configuration for sudo\n#auth sufficient pam_tid.so\n" + } + } + + // Encode content in Base64 to avoid any quoting, newline, or temp-file sandbox issues + let base64 = Data(content.utf8).base64EncodedString() + let command = "/bin/chmod 644 \(pamSudoLocalPath) 2>/dev/null || true; /bin/echo '\(base64)' | /usr/bin/base64 -d | /usr/bin/tee \(pamSudoLocalPath) > /dev/null; /bin/chmod 444 \(pamSudoLocalPath); /usr/sbin/chown root:wheel \(pamSudoLocalPath)" + + do { + _ = try await PrivilegedTaskRunner.runAsAdmin(command: command) + refreshTouchIDStatus() + Logger.maintenance.info("Touch ID for sudo set to \(enabled)") + } catch { + self.errorMessage = error.localizedDescription + Logger.maintenance.error("Failed to set Touch ID for sudo: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + public func rebuildSpotlightIndex() async throws { + isReindexingSpotlight = true + errorMessage = nil + spotlightStatusMessage = nil + defer { isReindexingSpotlight = false } + + // mdutil -E -i on / re-enables indexing and erases the store on the root volume + let cmd = "/usr/bin/mdutil -E -i on /" + do { + let output = try await PrivilegedTaskRunner.runAsAdmin(command: cmd) + spotlightStatusMessage = "settings_spotlight_reindex_success".localized + Logger.maintenance.info("Spotlight index rebuilt: \(output, privacy: .public)") + } catch { + self.errorMessage = error.localizedDescription + Logger.maintenance.error("Spotlight rebuild failed: \(error.localizedDescription, privacy: .public)") + throw error + } + } +} diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index dcb4411..e3a38a5 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -156,5 +156,5 @@ struct AboutView: View { } #Preview { - AboutView(availableUpdate: "2.1.0") + AboutView(availableUpdate: "2.1.1") } diff --git a/MacOSCleaner/Features/Settings/SettingsGeneralView.swift b/MacOSCleaner/Features/Settings/SettingsGeneralView.swift index f1a08b8..8bb7153 100644 --- a/MacOSCleaner/Features/Settings/SettingsGeneralView.swift +++ b/MacOSCleaner/Features/Settings/SettingsGeneralView.swift @@ -12,12 +12,17 @@ struct SettingsGeneralView: View { @State private var showResetConfirmation = false @State private var showInstructionSheet = false @State private var notificationStatus: UNAuthorizationStatus = .notDetermined + @State private var maintenanceService = SystemMaintenanceService() + @State private var touchIDCommandCopied = false + @State private var maintenanceAlertMessage: String? = nil + @State private var showMaintenanceAlert = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { fullDiskAccessCard appearanceCard + systemMaintenanceCard notificationsCard updatesCard resetCard @@ -25,10 +30,20 @@ struct SettingsGeneralView: View { .padding(20) .frame(maxWidth: .infinity, alignment: .leading) } - .onAppear { updateNotificationStatus() } + .onAppear { + updateNotificationStatus() + maintenanceService.refreshTouchIDStatus() + } .sheet(isPresented: $showInstructionSheet) { PermissionsView(permissionsManager: permissionsManager) } + .alert("error".localized, isPresented: $showMaintenanceAlert) { + Button("OK", role: .cancel) { } + } message: { + if let msg = maintenanceAlertMessage { + Text(msg) + } + } .confirmationDialog( "settings_reset_confirm_title".localized, isPresented: $showResetConfirmation, @@ -97,7 +112,7 @@ struct SettingsGeneralView: View { content: { SettingsLabeledControl( "settings_current_version".localized, - subtitle: "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.1.0")" + subtitle: "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.1.1")" ) { if isCheckingForUpdates { ProgressView().controlSize(.small) @@ -262,6 +277,132 @@ struct SettingsGeneralView: View { ) } + private var systemMaintenanceCard: some View { + GlassCard( + header: { + SettingsSectionHeader( + "settings_system_maintenance_title".localized, + subtitle: "settings_system_maintenance_sub".localized, + iconName: "wrench.and.screwdriver.fill", + iconColor: .indigo + ) + }, + content: { + VStack(spacing: 12) { + // Touch ID for sudo + // SIP on macOS 26 blocks writing /private/etc/pam.d/sudo_local even as root via AppleScript. + // The only reliable method is to have the user run the command themselves in Terminal. + if maintenanceService.isTouchIDHardwareAvailable { + VStack(alignment: .leading, spacing: 8) { + SettingsLabeledControl( + "settings_touchid_sudo_title".localized, + subtitle: "settings_touchid_sudo_sub".localized + ) { + if maintenanceService.isTouchIDForSudoEnabled { + StatusPill( + "settings_touchid_sudo_enabled".localized, + iconName: "checkmark.circle.fill", + style: .success, + size: .small + ) + } else { + HStack(spacing: 8) { + Button { + let cmd = touchIDEnableCommand + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(cmd, forType: .string) + touchIDCommandCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + touchIDCommandCopied = false + } + } label: { + Label( + touchIDCommandCopied + ? "settings_touchid_sudo_copied".localized + : "settings_touchid_sudo_copy_cmd".localized, + systemImage: touchIDCommandCopied ? "checkmark" : "doc.on.doc" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button { + maintenanceService.refreshTouchIDStatus() + } label: { + Label( + "settings_touchid_sudo_check_status".localized, + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if !maintenanceService.isTouchIDForSudoEnabled { + Text("settings_touchid_sudo_hint".localized) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + SettingsDivider() + } + + // Spotlight Reindexing + SettingsLabeledControl( + "settings_spotlight_reindex_title".localized, + subtitle: "settings_spotlight_reindex_sub".localized + ) { + HStack(spacing: 8) { + if let status = maintenanceService.spotlightStatusMessage { + Text(status) + .font(.caption) + .foregroundStyle(.green) + } + Button { + reindexSpotlight() + } label: { + HStack(spacing: 6) { + if maintenanceService.isReindexingSpotlight { + ProgressView() + .controlSize(.small) + } + Text(maintenanceService.isReindexingSpotlight + ? "settings_spotlight_reindexing".localized + : "settings_spotlight_reindex_button".localized) + } + .frame(minWidth: 140) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(maintenanceService.isReindexingSpotlight) + } + } + } + } + ) + } + + // The command that enables Touch ID for sudo. + // /private/etc/pam.d is SIP-protected on macOS 26: cannot be written by any sandboxed process, + // even with root via AppleScript. User must run this in Terminal directly. + private var touchIDEnableCommand: String { + "sudo cp /etc/pam.d/sudo_local.template /etc/pam.d/sudo_local && sudo sed -i '' 's/^#auth/auth/' /etc/pam.d/sudo_local" + } + + private func reindexSpotlight() { + Task { + do { + try await maintenanceService.rebuildSpotlightIndex() + } catch { + maintenanceAlertMessage = error.localizedDescription + showMaintenanceAlert = true + } + } + } + private func updateNotificationStatus() { Task { let status = await NotificationManager.shared.checkAuthorizationStatus() diff --git a/MacOSCleaner/Features/Settings/SettingsModel.swift b/MacOSCleaner/Features/Settings/SettingsModel.swift index efcd658..775aace 100644 --- a/MacOSCleaner/Features/Settings/SettingsModel.swift +++ b/MacOSCleaner/Features/Settings/SettingsModel.swift @@ -76,6 +76,8 @@ struct SettingsSearchRegistry { SettingsItem(id: "language", category: .general, titleKey: "settings_language", subtitleKey: "settings_language_sub", keywords: ["language", "язык", "locale", "english", "ukrainian", "russian", "локализация"], iconName: "globe"), SettingsItem(id: "theme", category: .general, titleKey: "settings_theme", subtitleKey: "settings_theme_sub", keywords: ["theme", "тема", "dark", "light", "appearance", "оформление", "вид"], iconName: "paintbrush"), SettingsItem(id: "autoScan", category: .general, titleKey: "settings_auto_scan", subtitleKey: "settings_auto_scan_sub", keywords: ["auto", "scan", "startup", "автосканирование", "авто", "запуск"], iconName: "play.circle"), + SettingsItem(id: "touchIdSudo", category: .general, titleKey: "settings_touchid_sudo_title", subtitleKey: "settings_touchid_sudo_sub", keywords: ["touch id", "sudo", "pam", "отпечаток", "палец", "пароль", "биометрия", "touchid"], iconName: "touchid"), + SettingsItem(id: "spotlightReindex", category: .general, titleKey: "settings_spotlight_reindex_title", subtitleKey: "settings_spotlight_reindex_sub", keywords: ["spotlight", "index", "reindex", "mdutil", "поиск", "спотлайт", "переиндексация", "индекс"], iconName: "magnifyingglass.circle.fill"), SettingsItem(id: "reset", category: .general, titleKey: "settings_forget_everything", subtitleKey: "settings_forget_description", keywords: ["reset", "forget", "danger", "сброс", "очистить всё", "сбросить"], iconName: "arrow.counterclockwise"), // Permissions diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift index 1205560..bbf8e40 100644 --- a/MacOSCleaner/Features/Settings/SettingsView.swift +++ b/MacOSCleaner/Features/Settings/SettingsView.swift @@ -178,6 +178,6 @@ struct SettingsView: View { settings: AppSettings(), permissionsManager: PermissionsManager(), onForget: {}, - availableUpdate: .constant("2.1.0") + availableUpdate: .constant("2.1.1") ) } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index 4ac86c8..2630a3a 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -656,42 +656,36 @@ public actor UninstallerService { } var trashedURLs: [URL] = [] + let allTargets = [app.url] + deletionTargets + if bypassTrash { - try safetyManager.validate(url: app.url, policy: .uninstall) - do { - try fileManager.removeItem(at: app.url) - Logger.uninstaller.info("Permanently removed: \(app.url.path, privacy: .public)") - } catch { - Logger.uninstaller.error("removeItem failed '\(app.url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") - throw error - } - for target in deletionTargets { + var privilegedPaths: [String] = [] + for target in allTargets { do { try safetyManager.validate(url: target, policy: .uninstall) try fileManager.removeItem(at: target) Logger.uninstaller.debug("Removed: \(target.path, privacy: .public)") } catch { - Logger.uninstaller.warning("removeItem failed '\(target.lastPathComponent, privacy: .public)': \(error.localizedDescription, privacy: .public)") + if Self.isPermissionError(error) { + privilegedPaths.append(target.path) + } else if target == app.url { + Logger.uninstaller.error("removeItem failed '\(target.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") + throw error + } else { + Logger.uninstaller.warning("removeItem failed '\(target.lastPathComponent, privacy: .public)': \(error.localizedDescription, privacy: .public)") + } } } - } else { - do { - let trashed = try await trashManager.trashItem(at: app.url, policy: .uninstall) - trashedURLs.append(trashed) - Logger.uninstaller.info("Trashed: \(app.url.path, privacy: .public)") - } catch { - Logger.uninstaller.error("trashItem failed '\(app.url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") - throw error - } - for target in deletionTargets { - do { - let trashed = try await trashManager.trashItem(at: target, policy: .uninstall) - trashedURLs.append(trashed) - Logger.uninstaller.debug("Trashed: \(target.path, privacy: .public)") - } catch { - Logger.uninstaller.warning("trashItem related '\(target.lastPathComponent, privacy: .public)': \(error.localizedDescription, privacy: .public)") - } + + if !privilegedPaths.isEmpty { + Logger.uninstaller.info("Executing single privileged removal for \(privilegedPaths.count) item(s)") + let escaped = privilegedPaths.map { "'\($0.replacingOccurrences(of: "'", with: "'\\''"))'" }.joined(separator: " ") + _ = try await PrivilegedTaskRunner.runAsAdmin(command: "/bin/rm -rf \(escaped)") + Logger.uninstaller.info("Permanently removed via admin privileges: \(privilegedPaths.count) item(s)") } + } else { + trashedURLs = try await trashManager.trashItems(urls: allTargets, policy: .uninstall) + Logger.uninstaller.info("Trashed \(trashedURLs.count) item(s) for '\(app.name, privacy: .public)'") } if let bundleID = app.bundleID { @@ -704,7 +698,8 @@ public actor UninstallerService { } // Only permanently delete items we just moved into Trash — never empty whole ~/.Trash. - if emptyTrashImmediately, !bypassTrash, !trashedURLs.isEmpty { + // Fires for both normal trash path and bypassTrash permission-error fallback. + if emptyTrashImmediately, !trashedURLs.isEmpty { do { try await trashManager.requestTrashAccess() _ = try await trashManager.permanentlyDelete(urls: trashedURLs) @@ -733,6 +728,12 @@ public actor UninstallerService { // MARK: - Private helpers + /// NSCocoaErrorDomain 513 = NSFileWriteNoPermissionError (maps to POSIX EPERM/EACCES). + static func isPermissionError(_ error: Error) -> Bool { + let code = (error as NSError).code + return code == NSFileWriteNoPermissionError || code == Int(EPERM) || code == Int(EACCES) + } + /// Mail message storage must never be offered as an app residual. /// ~/Library/Mail/Bundles stays allowed — Mail plugins are legitimate residuals. static func isProtectedMailPath(_ path: String, homeDirectory: String = NSHomeDirectory()) -> Bool { diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index 4679be2..5915c00 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -18,6 +18,8 @@ struct UninstallerView: View { @State private var isTargeted = false @State private var showingConfirmation = false @State private var isLoading = false + @State private var isUninstalling = false + @State private var uninstallingAppName = "" @State private var deepScanCache: [String: UninstallerService.AppInfo] = [:] @State private var isDeepScanning = false @State private var deepScanCompleted = 0 @@ -75,14 +77,17 @@ struct UninstallerView: View { } List(filteredApps) { app in let unscan = app.scanState != .deepScanned + let isThisAppUninstalling = isUninstalling && (selectedApp?.id == app.id) AppRowView( app: app, formatter: formatter, showRelatedFiles: settings.showRelatedFiles, - isUnscannable: unscan + isUnscannable: unscan, + isUninstalling: isThisAppUninstalling ) .contentShape(Rectangle()) .onTapGesture { + guard !isUninstalling else { return } guard app.scanState == .deepScanned else { return } selectedVersionID = nil selectedApp = app @@ -105,7 +110,30 @@ struct UninstallerView: View { // Detail Area ZStack { - if let app = selectedApp { + if isUninstalling { + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.4) + .controlSize(.large) + .padding(.bottom, 4) + + VStack(spacing: 6) { + Text(String(format: "uninstaller_uninstalling_app".localized, uninstallingAppName)) + .font(.title3) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("uninstaller_uninstalling_sub".localized) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .glassCard(cornerRadius: 16) + .padding(24) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } else if let app = selectedApp { appDetailView(app) .frame(maxWidth: .infinity) } else { @@ -113,6 +141,7 @@ struct UninstallerView: View { .frame(maxWidth: .infinity) } } + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isUninstalling) .layoutPriority(1) // Occupy remaining space } .padding(.top, 4) @@ -249,7 +278,12 @@ struct UninstallerView: View { } private func uninstall(_ app: UninstallerService.AppInfo) { + uninstallingAppName = app.name + isUninstalling = true Task { + defer { + isUninstalling = false + } do { try await service.uninstall( app: app, @@ -272,7 +306,12 @@ struct UninstallerView: View { } private func uninstallVersion(_ versionApp: UninstallerService.AppInfo, from parentApp: UninstallerService.AppInfo) { + uninstallingAppName = "\(parentApp.name) v\(versionApp.version)" + isUninstalling = true Task { + defer { + isUninstalling = false + } do { try await service.uninstall( app: versionApp, @@ -570,13 +609,20 @@ struct UninstallerView: View { .font(.headline) Button(action: { showingConfirmation = true }) { - Text("uninstaller_button_uninstall".localized) - .font(.headline) - .frame(maxWidth: 300) - .frame(height: 32) + HStack(spacing: 8) { + if isUninstalling { + ProgressView() + .controlSize(.small) + } + Text(isUninstalling ? "uninstaller_uninstalling".localized : "uninstaller_button_uninstall".localized) + .font(.headline) + } + .frame(maxWidth: 300) + .frame(height: 32) } .destructiveGlassButtonStyle() .controlSize(.large) + .disabled(isUninstalling) } } @@ -837,10 +883,15 @@ struct AppRowView: View { let formatter: ByteCountFormatter let showRelatedFiles: Bool let isUnscannable: Bool + var isUninstalling: Bool = false var body: some View { HStack(spacing: 12) { - if let iconData = app.iconData, let nsImage = NSImage(data: iconData) { + if isUninstalling { + ProgressView() + .controlSize(.small) + .frame(width: 32, height: 32) + } else if let iconData = app.iconData, let nsImage = NSImage(data: iconData) { Image(nsImage: nsImage) .resizable() .frame(width: 32, height: 32) @@ -865,7 +916,11 @@ struct AppRowView: View { .background(Capsule().fill(Color.purple.opacity(0.15))) } } - if isUnscannable { + if isUninstalling { + Text("uninstaller_uninstalling".localized) + .font(.caption) + .foregroundColor(.accentColor) + } else if isUnscannable { Text("uninstaller.analyzing".localized) .font(.caption) .foregroundColor(.secondary.opacity(0.5)) diff --git a/MacOSCleaner/Infrastructure/TrashManager.swift b/MacOSCleaner/Infrastructure/TrashManager.swift index 95a0803..cfbe2ce 100644 --- a/MacOSCleaner/Infrastructure/TrashManager.swift +++ b/MacOSCleaner/Infrastructure/TrashManager.swift @@ -21,21 +21,84 @@ public actor TrashManager { } @discardableResult - public func trashItem(at url: URL, policy: DeletionPolicy = .cleanup) throws -> URL { - try safetyManager.validate(url: url, policy: policy) - - var resultingURL: NSURL? - do { - try fileManager.trashItem(at: url, resultingItemURL: &resultingURL) - guard let result = resultingURL as URL? else { - throw TrashError.trashOperationFailed("Could not determine resulting URL in Trash.") + public func trashItem(at url: URL, policy: DeletionPolicy = .cleanup) async throws -> URL { + let results = try await trashItems(urls: [url], policy: policy) + guard let first = results.first else { + throw TrashError.trashOperationFailed("No item trashed") + } + return first + } + + /// Trashes multiple items efficiently, batching any privileged operations so the user + /// is prompted for authentication at most ONCE for the entire operation. + @discardableResult + public func trashItems(urls: [URL], policy: DeletionPolicy = .cleanup) async throws -> [URL] { + guard !urls.isEmpty else { return [] } + + for url in urls { + try safetyManager.validate(url: url, policy: policy) + } + + var trashedURLs: [URL] = [] + var failedURLs: [URL] = [] + + // 1. Try standard FileManager.trashItem on MainActor for all items (0 prompts for user files) + for url in urls { + var resultingURL: NSURL? + var success = false + do { + try await MainActor.run { + try FileManager.default.trashItem(at: url, resultingItemURL: &resultingURL) + } + if let result = resultingURL as URL? { + trashedURLs.append(result) + success = true + Logger.trash.debug("Trashed item via FileManager: \(url.path, privacy: .public)") + } + } catch { + Logger.trash.debug("FileManager.trashItem failed for '\(url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") + } + + if !success { + failedURLs.append(url) } - Logger.trash.debug("Trashed item: \(url.path, privacy: .public)") - return result + } + + guard !failedURLs.isEmpty else { + return trashedURLs + } + + // 2. For items that need elevated permissions, batch them into a single privileged command (1 prompt max) + let trashDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash") + let trashRoot = trashDir.path + let escapedTrashRoot = "'\(trashRoot.replacingOccurrences(of: "'", with: "'\\''"))'" + let uid = getuid() + let gid = getgid() + + var commands: [String] = [] + var batchTargetURLs: [URL] = [] + + for url in failedURLs { + let escapedSource = "'\(url.path.replacingOccurrences(of: "'", with: "'\\''"))'" + let targetURL = trashDir.appendingPathComponent(url.lastPathComponent) + let targetPath = targetURL.path + let escapedTarget = "'\(targetPath.replacingOccurrences(of: "'", with: "'\\''"))'" + + commands.append("/bin/mv \(escapedSource) \(escapedTrashRoot)/ && /usr/sbin/chown -R \(uid):\(gid) \(escapedTarget)") + batchTargetURLs.append(targetURL) + } + + let singleBatchCmd = commands.joined(separator: " && ") + do { + _ = try await PrivilegedTaskRunner.runAsAdmin(command: singleBatchCmd) + trashedURLs.append(contentsOf: batchTargetURLs) + Logger.trash.info("Trashed \(failedURLs.count) privileged item(s) in a single batch") } catch { - Logger.trash.error("trashItem failed '\(url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") + Logger.trash.error("Batch privileged trash failed: \(error.localizedDescription, privacy: .public)") throw TrashError.trashOperationFailed(error.localizedDescription) } + + return trashedURLs } /// Wholesale `~/.Trash` empty is disabled — would delete unrelated user items. @@ -49,35 +112,49 @@ public actor TrashManager { } /// Permanently deletes only the given URLs (typically items just moved into Trash). - /// Does not empty unrelated Trash contents. + /// Batches any privileged items so authentication is requested at most ONCE. @discardableResult public func permanentlyDelete(urls: [URL]) async throws -> Int64 { try await ensureAccess() var totalFreed: Int64 = 0 - var failedCount = 0 + var failedURLs: [URL] = [] for url in urls { do { try Task.checkCancellation() - try safetyManager.validate(url: url) guard fileManager.fileExists(atPath: url.path) else { continue } let size = fileManager.getDirectorySize(url: url) - try fileManager.removeItem(at: url) - totalFreed += size - Logger.trash.debug("Permanently deleted: \(url.path, privacy: .public) (\(size) bytes)") + do { + try fileManager.removeItem(at: url) + totalFreed += size + Logger.trash.debug("Permanently deleted: \(url.path, privacy: .public) (\(size) bytes)") + } catch { + if (error as NSError).code == NSFileWriteNoPermissionError || (error as NSError).code == Int(EPERM) || (error as NSError).code == Int(EACCES) { + failedURLs.append(url) + } else { + throw error + } + } } catch is CancellationError { throw CancellationError() } catch { - failedCount += 1 Logger.trash.error("Failed to delete '\(url.lastPathComponent, privacy: .public)': \(error.localizedDescription, privacy: .public)") } } - if failedCount > 0 { - Logger.trash.warning("permanentlyDelete: \(urls.count) items, \(failedCount) failures, \(totalFreed) bytes freed") - } else { - Logger.trash.info("permanentlyDelete: \(urls.count) items deleted, \(totalFreed) bytes freed") + if !failedURLs.isEmpty { + let escaped = failedURLs.map { "'\($0.path.replacingOccurrences(of: "'", with: "'\\''"))'" }.joined(separator: " ") + do { + _ = try await PrivilegedTaskRunner.runAsAdmin(command: "/bin/rm -rf \(escaped)") + for url in failedURLs { + let size = fileManager.getDirectorySize(url: url) + totalFreed += size + } + Logger.trash.info("Permanently deleted \(failedURLs.count) privileged item(s) in a single batch") + } catch { + Logger.trash.error("Batch privileged delete failed: \(error.localizedDescription, privacy: .public)") + } } return totalFreed diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index ebe53b3..e9983c9 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -228,6 +228,7 @@ EDD3940083A8EC447172ED6E /* CleanupOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68F3F184D878FFEFC54B21 /* CleanupOptionsTests.swift */; }; EDF297B65D382745EED0B1DB /* UninstallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF1EDB5329DABAA20BCAED2 /* UninstallSnapshot.swift */; }; EE98F8870E81508DA3DD855F /* SettingsAdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3CAAC952028CD7C4FAFCF7 /* SettingsAdvancedView.swift */; }; + EEAD0BC18B960477984ECA5B /* SystemMaintenanceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCA3EAE8C88730B9286962CA /* SystemMaintenanceService.swift */; }; F00B1892329F1ED50C46574B /* EvidenceSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86C1024E8C23494818532A4 /* EvidenceSourceTests.swift */; }; F094BF7BF4EA75B947A0DF3F /* DuplicateFinderEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340615D1892577153030AAAC /* DuplicateFinderEngine.swift */; }; F0AC9987DA2D81F6707391FE /* SettingsCleanupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C98C7B7FC3C6CF1967423B7 /* SettingsCleanupView.swift */; }; @@ -498,6 +499,7 @@ F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoringWeights.swift; sourceTree = ""; }; FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunnerTests.swift; sourceTree = ""; }; FBAF8D65EF28A9DB89B6020A /* GeneratedCleanupPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedCleanupPaths.swift; sourceTree = ""; }; + FCA3EAE8C88730B9286962CA /* SystemMaintenanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMaintenanceService.swift; sourceTree = ""; }; FD67C865E2D4BF1DF1A3E454 /* HelperAppCollapser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperAppCollapser.swift; sourceTree = ""; }; FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSource.swift; sourceTree = ""; }; FDFA9788CE80DD0C46A4DBCF /* GlassOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassOverlayManager.swift; sourceTree = ""; }; @@ -828,6 +830,7 @@ 4BF21A13D26648B76B0A3183 /* GeneratedCleanupPaths+AIUserContent.swift */, 3F27CB6D5AF1785C1A04EA84 /* PrivateCatalogSnapshot.swift */, 9D5372D4A260420EA85CF98A /* RegistryTypes.swift */, + FCA3EAE8C88730B9286962CA /* SystemMaintenanceService.swift */, 9CABB543E062966462CFB6E6 /* TimeMachineScanner.swift */, 753F374AA91758652C409A39 /* TransactionJournal.swift */, ); @@ -1328,6 +1331,7 @@ 6A91F8F5E97E9DC385D90906 /* SteamRule.swift in Sources */, C54E43E230C79CFDE5EAD04C /* String+Localization.swift in Sources */, DE4F65F07814A5139023D54F /* SystemInfo.swift in Sources */, + EEAD0BC18B960477984ECA5B /* SystemMaintenanceService.swift in Sources */, 2E38FD4A6C11C4E5E11FF511 /* TerminalRule.swift in Sources */, 40E1527BCA6EC0FDC18716A6 /* TimeMachineScanner.swift in Sources */, B17DA1284BD059BAA16189CF /* TransactionJournal.swift in Sources */, @@ -1400,7 +1404,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = singlefile; @@ -1495,7 +1499,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.1.1; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/MacOSCleaner/Resources/de.lproj/Localizable.strings b/MacOSCleaner/Resources/de.lproj/Localizable.strings index 2f3ae20..8df49da 100644 --- a/MacOSCleaner/Resources/de.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "Dateien werden in den Papierkorb verschoben."; "uninstaller_space_reclaim" = "Freizugebender Speicherplatz: %@"; "uninstaller_button_uninstall" = "Anwendung deinstallieren"; +"uninstaller_uninstalling" = "Wird deinstalliert..."; +"uninstaller_uninstalling_app" = "„%@“ wird deinstalliert..."; +"uninstaller_uninstalling_sub" = "Bitte warten, Anwendungsdateien werden entfernt..."; "uninstaller_related_files_count" = "%lld zugehörige Dateien gefunden"; "uninstaller_developer_components" = "Zugehörige Entwicklerdaten"; "uninstaller_developer_components_description" = "Verwalten Sie diese Objekte in der Smart-Bereinigung."; @@ -681,8 +684,23 @@ "settings_appearance_language_sub" = "App-Oberfläche anpassen"; "settings_language_sub" = "Sprache der Benutzeroberfläche"; "settings_theme_sub" = "Farbschema"; -"settings_tooltips_sub" = "Hilfreiche Popover beim Darüberbewegen"; -"settings_software_updates" = "Software-Updates"; +"settings_tooltips_sub" = "Hilfreiche Tooltips beim Hovern"; +"settings_system_maintenance_title" = "Systemwartung"; +"settings_system_maintenance_sub" = "Systemoptimierungen für macOS"; +"settings_touchid_sudo_title" = "Touch ID für sudo"; +"settings_touchid_sudo_sub" = "Sudo-Befehle mit Touch ID bestätigen (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID auf diesem Mac nicht verfügbar"; +"settings_touchid_sudo_enabled" = "Aktiviert"; +"settings_touchid_sudo_copy_cmd" = "Befehl kopieren"; +"settings_touchid_sudo_copied" = "Kopiert!"; +"settings_touchid_sudo_check_status" = "Status prüfen"; +"settings_touchid_sudo_hint" = "Füge diesen Befehl in Terminal ein und führe ihn aus, um Touch ID für sudo zu aktivieren. SIP verhindert, dass Apps in /etc/pam.d/ schreiben."; +"settings_spotlight_reindex_title" = "Spotlight-Index neu aufbauen"; +"settings_spotlight_reindex_sub" = "Erstellt den Suchindex neu, um Suchfehler und Speicherberechnungen zu beheben (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Neu aufbauen"; +"settings_spotlight_reindexing" = "Wird neu aufgebaut..."; +"settings_spotlight_reindex_success" = "Spotlight-Neuindizierung gestartet"; +"settings_software_updates" = "Softwareaktualisierungen"; "settings_software_updates_sub" = "Versionsprüfung"; "settings_current_version" = "Aktuelle Version"; diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 8e73b6e..5005ced 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -377,6 +377,9 @@ "uninstaller_action_info_trash_sub" = "Files are moved to the Trash and can be restored."; "uninstaller_space_reclaim" = "Total Space to Reclaim: %@"; "uninstaller_button_uninstall" = "Uninstall Application"; +"uninstaller_uninstalling" = "Uninstalling..."; +"uninstaller_uninstalling_app" = "Uninstalling %@..."; +"uninstaller_uninstalling_sub" = "Please wait, removing application files..."; "uninstaller_related_files_count" = "%lld Related Files Found"; "uninstaller_developer_components" = "Related Developer Data"; "uninstaller_developer_components_description" = "Manage these items in Smart Cleanup."; @@ -780,6 +783,21 @@ "settings_language_sub" = "Interface display language"; "settings_theme_sub" = "Color scheme appearance"; "settings_tooltips_sub" = "Helpful popovers on hover"; +"settings_system_maintenance_title" = "System Maintenance"; +"settings_system_maintenance_sub" = "System optimizations for macOS"; +"settings_touchid_sudo_title" = "Touch ID for sudo"; +"settings_touchid_sudo_sub" = "Allow authenticating sudo commands with Touch ID (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID not available on this Mac"; +"settings_touchid_sudo_enabled" = "Enabled"; +"settings_touchid_sudo_copy_cmd" = "Copy Terminal Command"; +"settings_touchid_sudo_copied" = "Copied!"; +"settings_touchid_sudo_check_status" = "Check Status"; +"settings_touchid_sudo_hint" = "Paste this command in Terminal and run it to enable Touch ID for sudo. SIP prevents apps from writing to /etc/pam.d/ directly."; +"settings_spotlight_reindex_title" = "Rebuild Spotlight Index"; +"settings_spotlight_reindex_sub" = "Forces rebuilding search index to fix search errors and storage calculation (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Rebuild Index"; +"settings_spotlight_reindexing" = "Rebuilding..."; +"settings_spotlight_reindex_success" = "Spotlight reindexing started"; "settings_software_updates" = "Software Updates"; "settings_software_updates_sub" = "Version checks"; "settings_current_version" = "Current Version"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index bef3c47..ddf6736 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -377,6 +377,9 @@ "uninstaller_action_info_trash_sub" = "Los archivos se mueven a la Papelera y se pueden restaurar."; "uninstaller_space_reclaim" = "Espacio total a recuperar: %@"; "uninstaller_button_uninstall" = "Desinstalar aplicación"; +"uninstaller_uninstalling" = "Desinstalando..."; +"uninstaller_uninstalling_app" = "Desinstalando «%@»..."; +"uninstaller_uninstalling_sub" = "Por favor espere, eliminando archivos..."; "uninstaller_related_files_count" = "%lld archivos relacionados encontrados"; "uninstaller_developer_components" = "Datos relacionados del desarrollador"; "uninstaller_developer_components_description" = "Gestione estos datos en Smart Cleanup."; @@ -775,9 +778,24 @@ "settings_appearance_language" = "Apariencia e idioma"; "settings_appearance_language_sub" = "Personalizar interfaz de usuario"; -"settings_language_sub" = "Idioma de pantalla de la interfaz"; -"settings_theme_sub" = "Esquema de colores de la aplicación"; +"settings_language_sub" = "Idioma de visualización"; +"settings_theme_sub" = "Combinación de colores"; "settings_tooltips_sub" = "Sugerencias al pasar el cursor"; +"settings_system_maintenance_title" = "Mantenimiento del sistema"; +"settings_system_maintenance_sub" = "Optimizaciones del sistema para macOS"; +"settings_touchid_sudo_title" = "Touch ID para sudo"; +"settings_touchid_sudo_sub" = "Confirmar comandos sudo con Touch ID (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID no disponible en este Mac"; +"settings_touchid_sudo_enabled" = "Activado"; +"settings_touchid_sudo_copy_cmd" = "Copiar comando"; +"settings_touchid_sudo_copied" = "¡Copiado!"; +"settings_touchid_sudo_check_status" = "Comprobar estado"; +"settings_touchid_sudo_hint" = "Pega este comando en Terminal y ejécutalo para activar Touch ID para sudo. SIP impide que las apps escriban en /etc/pam.d/ directamente."; +"settings_spotlight_reindex_title" = "Reconstruir índice de Spotlight"; +"settings_spotlight_reindex_sub" = "Fuerza la reconstrucción del índice para corregir búsquedas y cálculo de espacio (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Reconstruir"; +"settings_spotlight_reindexing" = "Reconstruyendo..."; +"settings_spotlight_reindex_success" = "Reindexación de Spotlight iniciada"; "settings_software_updates" = "Actualizaciones de software"; "settings_software_updates_sub" = "Comprobación de versiones"; "settings_current_version" = "Versión actual"; diff --git a/MacOSCleaner/Resources/fr.lproj/Localizable.strings b/MacOSCleaner/Resources/fr.lproj/Localizable.strings index c689bdd..65f022f 100644 --- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "Les fichiers sont déplacés dans la Corbeille."; "uninstaller_space_reclaim" = "Espace total à récupérer : %@"; "uninstaller_button_uninstall" = "Désinstaller l'application"; +"uninstaller_uninstalling" = "Désinstallation..."; +"uninstaller_uninstalling_app" = "Désinstallation de « %@ »..."; +"uninstaller_uninstalling_sub" = "Veuillez patienter, suppression des fichiers..."; "uninstaller_related_files_count" = "%lld fichiers associés trouvés"; "uninstaller_developer_components" = "Composants développeur associés"; "uninstaller_developer_components_description" = "Gérez ces éléments dans le Nettoyage intelligent."; @@ -678,9 +681,24 @@ "settings_appearance_language" = "Apparence & Langue"; "settings_appearance_language_sub" = "Personnaliser l'interface"; "settings_language_sub" = "Langue d'affichage de l'interface"; -"settings_theme_sub" = "Thème de couleur"; -"settings_tooltips_sub" = "Info-bulles d'aide au survol"; -"settings_software_updates" = "Mises à jour manuelles"; +"settings_theme_sub" = "Schéma de couleurs"; +"settings_tooltips_sub" = "Infobulles au survol"; +"settings_system_maintenance_title" = "Maintenance système"; +"settings_system_maintenance_sub" = "Optimisations système pour macOS"; +"settings_touchid_sudo_title" = "Touch ID pour sudo"; +"settings_touchid_sudo_sub" = "Confirmer les commandes sudo avec Touch ID (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID n'est pas disponible sur ce Mac"; +"settings_touchid_sudo_enabled" = "Activé"; +"settings_touchid_sudo_copy_cmd" = "Copier la commande"; +"settings_touchid_sudo_copied" = "Copié !"; +"settings_touchid_sudo_check_status" = "Vérifier le statut"; +"settings_touchid_sudo_hint" = "Collez cette commande dans le Terminal et exécutez-la pour activer Touch ID pour sudo. La SIP empêche les apps d'accéder directement à /etc/pam.d/."; +"settings_spotlight_reindex_title" = "Reconstruire l'index Spotlight"; +"settings_spotlight_reindex_sub" = "Force la reconstruction de l'index pour corriger la recherche et le calcul d'espace (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Reconstruire"; +"settings_spotlight_reindexing" = "Reconstruction..."; +"settings_spotlight_reindex_success" = "Réindexation Spotlight lancée"; +"settings_software_updates" = "Mises à jour logicielles"; "settings_software_updates_sub" = "Vérification des versions"; "settings_current_version" = "Version actuelle"; diff --git a/MacOSCleaner/Resources/it.lproj/Localizable.strings b/MacOSCleaner/Resources/it.lproj/Localizable.strings index a81b219..fca786c 100644 --- a/MacOSCleaner/Resources/it.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "I file vengono spostati nel Cestino."; "uninstaller_space_reclaim" = "Spazio totale da recuperare: %@"; "uninstaller_button_uninstall" = "Disinstalla applicazione"; +"uninstaller_uninstalling" = "Disinstallazione..."; +"uninstaller_uninstalling_app" = "Disinstallazione di «%@»..."; +"uninstaller_uninstalling_sub" = "Attendere, rimozione dei file in corso..."; "uninstaller_related_files_count" = "%lld file associati trovati"; "uninstaller_developer_components" = "Dati sviluppatore associati"; "uninstaller_developer_components_description" = "Gestisci questi elementi nella Pulizia intelligente."; @@ -672,6 +675,21 @@ "settings_language_sub" = "Lingua dell'interfaccia"; "settings_theme_sub" = "Schema colori"; "settings_tooltips_sub" = "Suggerimenti al passaggio del mouse"; +"settings_system_maintenance_title" = "Manutenzione del sistema"; +"settings_system_maintenance_sub" = "Ottimizzazioni di sistema per macOS"; +"settings_touchid_sudo_title" = "Touch ID per sudo"; +"settings_touchid_sudo_sub" = "Conferma i comandi sudo con Touch ID (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID non disponibile su questo Mac"; +"settings_touchid_sudo_enabled" = "Attivato"; +"settings_touchid_sudo_copy_cmd" = "Copia comando"; +"settings_touchid_sudo_copied" = "Copiato!"; +"settings_touchid_sudo_check_status" = "Verifica stato"; +"settings_touchid_sudo_hint" = "Incolla questo comando nel Terminale ed eseguilo per abilitare Touch ID per sudo. SIP impedisce alle app di scrivere in /etc/pam.d/ direttamente."; +"settings_spotlight_reindex_title" = "Ricostruisci indice Spotlight"; +"settings_spotlight_reindex_sub" = "Forza la ricostruzione dell'indice per correggere la ricerca e il calcolo dello spazio (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Ricostruisci"; +"settings_spotlight_reindexing" = "Ricostruzione in corso..."; +"settings_spotlight_reindex_success" = "Reindicizzazione Spotlight avviata"; "settings_software_updates" = "Aggiornamenti software"; "settings_software_updates_sub" = "Verifica versione"; "settings_current_version" = "Versione attuale"; diff --git a/MacOSCleaner/Resources/ja.lproj/Localizable.strings b/MacOSCleaner/Resources/ja.lproj/Localizable.strings index 9937eeb..e4a9c20 100644 --- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "ゴミ箱へ移動するため復元可能です。"; "uninstaller_space_reclaim" = "解放予定容量: %@"; "uninstaller_button_uninstall" = "アプリをアンインストール"; +"uninstaller_uninstalling" = "アンインストール中..."; +"uninstaller_uninstalling_app" = "「%@」をアンインストール中..."; +"uninstaller_uninstalling_sub" = "アプリケーションファイルを削除しています。しばらくお待ちください..."; "uninstaller_related_files_count" = "%lld 個の関連ファイルを検出"; "uninstaller_developer_components" = "関連する開発者データ"; "uninstaller_developer_components_description" = "スマートクリーンアップで管理できます。"; @@ -670,8 +673,23 @@ "settings_appearance_language" = "外観と言語"; "settings_appearance_language_sub" = "インターフェースをカスタマイズ"; "settings_language_sub" = "表示言語"; -"settings_theme_sub" = "テーマ"; -"settings_tooltips_sub" = "ツールチップ表示"; +"settings_theme_sub" = "カラースキーム"; +"settings_tooltips_sub" = "ホバー時のツールチップ"; +"settings_system_maintenance_title" = "システムメンテナンス"; +"settings_system_maintenance_sub" = "macOS 向けシステム最適化"; +"settings_touchid_sudo_title" = "sudo 用 Touch ID"; +"settings_touchid_sudo_sub" = "Touch ID で sudo コマンドを認証 (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "この Mac では Touch ID を使用できません"; +"settings_touchid_sudo_enabled" = "有効"; +"settings_touchid_sudo_copy_cmd" = "コマンドをコピー"; +"settings_touchid_sudo_copied" = "コピーしました!"; +"settings_touchid_sudo_check_status" = "ステータスを確認"; +"settings_touchid_sudo_hint" = "このコマンドをターミナルに貼り付けて実行すると、sudo で Touch ID を有効にできます。SIP により、アプリが /etc/pam.d/ に直接書き込むことはできません。"; +"settings_spotlight_reindex_title" = "Spotlight インデックスを再構築"; +"settings_spotlight_reindex_sub" = "検索インデックスを強制再構築して検索エラーや容量計算の不具合を修正 (mdutil -E /)"; +"settings_spotlight_reindex_button" = "再構築"; +"settings_spotlight_reindexing" = "再構築中..."; +"settings_spotlight_reindex_success" = "Spotlight の再インデックスを開始しました"; "settings_software_updates" = "ソフトウェアアップデート"; "settings_software_updates_sub" = "バージョン確認"; "settings_current_version" = "現在のバージョン"; diff --git a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings index ca301d1..4981891 100644 --- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "Os arquivos são movidos para o Lixo."; "uninstaller_space_reclaim" = "Espaço total a recuperar: %@"; "uninstaller_button_uninstall" = "Desinstalar Aplicativo"; +"uninstaller_uninstalling" = "Desinstalando..."; +"uninstaller_uninstalling_app" = "Desinstalando «%@»..."; +"uninstaller_uninstalling_sub" = "Aguarde, removendo arquivos do aplicativo..."; "uninstaller_related_files_count" = "%lld arquivos associados encontrados"; "uninstaller_developer_components" = "Dados de desenvolvedor associados"; "uninstaller_developer_components_description" = "Gerencie estes itens na Limpeza Inteligente."; @@ -671,7 +674,22 @@ "settings_appearance_language_sub" = "Personalizar interface do app"; "settings_language_sub" = "Idioma de exibição"; "settings_theme_sub" = "Esquema de cores"; -"settings_tooltips_sub" = "Dicas visuais ao passar o mouse"; +"settings_tooltips_sub" = "Dicas ao passar o mouse"; +"settings_system_maintenance_title" = "Manutenção do Sistema"; +"settings_system_maintenance_sub" = "Otimizações do sistema para macOS"; +"settings_touchid_sudo_title" = "Touch ID para sudo"; +"settings_touchid_sudo_sub" = "Confirmar comandos sudo com Touch ID (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID não disponível neste Mac"; +"settings_touchid_sudo_enabled" = "Ativado"; +"settings_touchid_sudo_copy_cmd" = "Copiar comando"; +"settings_touchid_sudo_copied" = "Copiado!"; +"settings_touchid_sudo_check_status" = "Verificar status"; +"settings_touchid_sudo_hint" = "Cole este comando no Terminal e execute-o para ativar o Touch ID para sudo. O SIP impede que apps escrevam em /etc/pam.d/ diretamente."; +"settings_spotlight_reindex_title" = "Reconstruir Índice do Spotlight"; +"settings_spotlight_reindex_sub" = "Força a reconstrução do índice para corrigir buscas e cálculo de espaço (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Reconstruir"; +"settings_spotlight_reindexing" = "Reconstruindo..."; +"settings_spotlight_reindex_success" = "Reindexação do Spotlight iniciada"; "settings_software_updates" = "Atualizações de Software"; "settings_software_updates_sub" = "Verificação de versão"; "settings_current_version" = "Versão atual"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 936fcad..05c909d 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -376,6 +376,9 @@ "uninstaller_action_info_trash_sub" = "Файлы перемещаются в Корзину и могут быть восстановлены."; "uninstaller_space_reclaim" = "Всего места к освобождению: %@"; "uninstaller_button_uninstall" = "Удалить приложение"; +"uninstaller_uninstalling" = "Удаление..."; +"uninstaller_uninstalling_app" = "Удаление «%@»..."; +"uninstaller_uninstalling_sub" = "Пожалуйста, подождите, удаляются файлы приложения..."; "uninstaller_related_files_count" = "Найдено связанных файлов: %lld"; "uninstaller_developer_components" = "Связанные данные разработчика"; "uninstaller_developer_components_description" = "Управляйте этими данными в Smart Cleanup."; @@ -777,6 +780,21 @@ "settings_language_sub" = "Язык отображения интерфейса"; "settings_theme_sub" = "Цветовая схема приложения"; "settings_tooltips_sub" = "Подсказки при наведении"; +"settings_system_maintenance_title" = "Системное обслуживание"; +"settings_system_maintenance_sub" = "Системные оптимизации для macOS"; +"settings_touchid_sudo_title" = "Touch ID для sudo"; +"settings_touchid_sudo_sub" = "Подтверждение команд sudo по отпечатку пальца (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID недоступен на этом Mac"; +"settings_touchid_sudo_enabled" = "Включено"; +"settings_touchid_sudo_copy_cmd" = "Скопировать команду"; +"settings_touchid_sudo_copied" = "Скопировано!"; +"settings_touchid_sudo_check_status" = "Проверить статус"; +"settings_touchid_sudo_hint" = "Вставьте эту команду в Терминал и выполните её, чтобы включить Touch ID для sudo. SIP запрещает приложениям писать в /etc/pam.d/ напрямую."; +"settings_spotlight_reindex_title" = "Переиндексация Spotlight"; +"settings_spotlight_reindex_sub" = "Принудительно перестраивает поисковый индекс для исправления поиска и расчета места (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Переиндексировать"; +"settings_spotlight_reindexing" = "Переиндексация..."; +"settings_spotlight_reindex_success" = "Переиндексация Spotlight запущена"; "settings_software_updates" = "Обновления ПО"; "settings_software_updates_sub" = "Проверка версий"; "settings_current_version" = "Текущая версия"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 020fc43..9e0fdc7 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -373,6 +373,9 @@ "uninstaller_action_info_trash_sub" = "Файли переміщуються в Смітник і можуть бути відновлені."; "uninstaller_space_reclaim" = "Всього місця до звільнення: %@"; "uninstaller_button_uninstall" = "Видалити додаток"; +"uninstaller_uninstalling" = "Видалення..."; +"uninstaller_uninstalling_app" = "Видалення «%@»..."; +"uninstaller_uninstalling_sub" = "Будь ласка, зачекайте, видаляються файли програми..."; "uninstaller_related_files_count" = "Знайдено пов'язаних файлів: %lld"; "uninstaller_developer_components" = "Пов'язані дані розробника"; "uninstaller_developer_components_description" = "Керуйте цими даними в Smart Cleanup."; @@ -774,6 +777,21 @@ "settings_language_sub" = "Мова відображення інтерфейсу"; "settings_theme_sub" = "Колірна схема додатку"; "settings_tooltips_sub" = "Підказки при наведенні"; +"settings_system_maintenance_title" = "Системне обслуговування"; +"settings_system_maintenance_sub" = "Системні оптимізації для macOS"; +"settings_touchid_sudo_title" = "Touch ID для sudo"; +"settings_touchid_sudo_sub" = "Підтвердження команд sudo за відбитком пальця (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "Touch ID недоступний на цьому Mac"; +"settings_touchid_sudo_enabled" = "Увімкнено"; +"settings_touchid_sudo_copy_cmd" = "Скопіювати команду"; +"settings_touchid_sudo_copied" = "Скопійовано!"; +"settings_touchid_sudo_check_status" = "Перевірити статус"; +"settings_touchid_sudo_hint" = "Вставте цю команду в Термінал і виконайте її, щоб вмікнути Touch ID для sudo. SIP забороняє запис в /etc/pam.d/ з програм."; +"settings_spotlight_reindex_title" = "Переіндексація Spotlight"; +"settings_spotlight_reindex_sub" = "Примусово перебудовує пошуковий індекс для виправлення пошуку та розрахунку місця (mdutil -E /)"; +"settings_spotlight_reindex_button" = "Переіндексувати"; +"settings_spotlight_reindexing" = "Переіндексація..."; +"settings_spotlight_reindex_success" = "Переіндексацію Spotlight запущено"; "settings_software_updates" = "Оновлення ПЗ"; "settings_software_updates_sub" = "Перевірка версій"; "settings_current_version" = "Поточна версія"; diff --git a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings index 046a984..5dc6e30 100644 --- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings @@ -364,6 +364,9 @@ "uninstaller_action_info_trash_sub" = "文件将被移至废纸篓,可手动还原。"; "uninstaller_space_reclaim" = "预计可释放空间:%@"; "uninstaller_button_uninstall" = "卸载应用程序"; +"uninstaller_uninstalling" = "正在卸载..."; +"uninstaller_uninstalling_app" = "正在卸载“%@”..."; +"uninstaller_uninstalling_sub" = "请稍候,正在删除应用程序文件..."; "uninstaller_related_files_count" = "找到 %lld 个关联文件"; "uninstaller_developer_components" = "关联的开发者组件"; "uninstaller_developer_components_description" = "可在“智能清理”中进行详细管理。"; @@ -670,8 +673,23 @@ "settings_appearance_language" = "外观与语言"; "settings_appearance_language_sub" = "个性化设置应用界面"; "settings_language_sub" = "界面显示语言"; -"settings_theme_sub" = "颜色风格"; -"settings_tooltips_sub" = "鼠标悬停提示框"; +"settings_theme_sub" = "色彩方案外观"; +"settings_tooltips_sub" = "悬停时的提示气泡"; +"settings_system_maintenance_title" = "系统维护"; +"settings_system_maintenance_sub" = "针对 macOS 的系统优化"; +"settings_touchid_sudo_title" = "用于 sudo 的 Touch ID"; +"settings_touchid_sudo_sub" = "允许使用 Touch ID 验证 sudo 命令 (/etc/pam.d/sudo_local)"; +"settings_touchid_sudo_unsupported" = "此 Mac 不支持 Touch ID"; +"settings_touchid_sudo_enabled" = "已启用"; +"settings_touchid_sudo_copy_cmd" = "复制命令"; +"settings_touchid_sudo_copied" = "已复制!"; +"settings_touchid_sudo_check_status" = "检查状态"; +"settings_touchid_sudo_hint" = "将此命令粘贴到终端并执行,即可为 sudo 启用 Touch ID。SIP 防止应用直接写入 /etc/pam.d/。"; +"settings_spotlight_reindex_title" = "重建 Spotlight 索引"; +"settings_spotlight_reindex_sub" = "强制重建搜索索引以修复搜索错误与空间计算问题 (mdutil -E /)"; +"settings_spotlight_reindex_button" = "重建索引"; +"settings_spotlight_reindexing" = "正在重建..."; +"settings_spotlight_reindex_success" = "Spotlight 重建索引已启动"; "settings_software_updates" = "软件更新"; "settings_software_updates_sub" = "版本检测"; "settings_current_version" = "当前版本"; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index e168c2b..0ef5049 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -74,7 +74,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleaner - MARKETING_VERSION: 2.1.0 + MARKETING_VERSION: 2.1.1 CURRENT_PROJECT_VERSION: 2 ENABLE_HARDENED_RUNTIME: YES SWIFT_VERSION: 6.0 diff --git a/README.md b/README.md index f72502c..f764023 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Language: Swift 6](https://img.shields.io/badge/Language-Swift%206-FA7343?logo=swift&logoColor=white)](https://swift.org) [![UI: SwiftUI](https://img.shields.io/badge/UI-SwiftUI-007AFF?logo=swift&logoColor=white)](https://developer.apple.com/documentation/swiftui/) [![Build: XcodeGen](https://img.shields.io/badge/Build-XcodeGen-black.svg?logo=xcode&logoColor=white)](https://github.com/yonaskolb/XcodeGen) -[![Version: 2.1.0](https://img.shields.io/badge/Release-2.1.0-brightgreen.svg)]() +[![Version: 2.1.1](https://img.shields.io/badge/Release-2.1.1-brightgreen.svg)]() [![Ko-fi](https://img.shields.io/badge/Ko--fi-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/alextkdev) @@ -88,6 +88,7 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All - **Scan Modes (Safe / Balanced)** — choose between *Safe* mode (depth 3, exact matches only, no Spotlight, highest confidence files) and *Balanced* mode (depth 5, full deep scan including Spotlight and fuzzy matching) to tailor uninstallation aggressiveness - **Background Deep Scanning** — apps are scanned thoroughly in the background; the UI updates in real time as each app's total size is finalized +- **Privileged Uninstallation & Batching** — root-owned applications located in `/Applications` are cleanly removed with exactly one administrative prompt per operation, accompanied by real-time progress indicators and action spinners - **Evidence-Based Forensics** — each candidate file is scored against 30 evidence types: identity, code signing, system integration, metadata, content analysis, graph relationships, and Launch Services registration - **Confidence Tiers** — `.guaranteed` (critical evidence), `.veryLikely`, `.possible`, or `.ignore` - **Developer Components** — detects and offers to clean Android SDK, Gradle/Maven, Xcode DerivedData, iOS Simulators, Flutter pub-cache, Docker containers, and Homebrew artifacts @@ -97,7 +98,9 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All **Smart Updates** 🔄 — automatic, lightweight background check for new versions on startup directly via GitHub Releases. Get gently notified when a new update is ready, without background daemons, persistent tracking, or extra dependencies. -**Settings** — modular Liquid Glass interface organized into General, Cleanup, Automation, Processes, Advanced, and About. Manage Full Disk Access and notifications through direct System Settings links, configure Debug Mode (hiding/showing detailed execution logs), Siri and Automator integrations, Apple Intelligence, themes, languages, scan-on-startup, Trash behavior, custom System Vendors, and more. +**Settings & System Maintenance** 🛠️ — modular Liquid Glass interface organized into General, Cleanup, Automation, Processes, Advanced, and About: +- **System Maintenance** — easily copy terminal commands to enable Touch ID for `sudo` authentication (`/etc/pam.d/sudo_local`) with real-time status verification, and trigger one-click Spotlight index rebuilding (`mdutil -E /`) to resolve search calculation glitches. +- **Preferences & Permissions** — manage Full Disk Access and notifications through direct System Settings links, configure Debug Mode (hiding/showing detailed execution logs), Siri and Automator integrations, Apple Intelligence, themes, languages, scan-on-startup, Trash behavior, custom System Vendors, and more. --- @@ -165,17 +168,15 @@ sudo xattr -r -c /Applications/MacOSCleaner.app --- -## 🧑‍💻 Currently Working On +## 🧑‍💻 Project Status & Feedback -Track active development, upcoming releases, and share your ideas in **[Discussion #12](https://github.com/AlexTkDev/MacOSCleaner/discussions/12)**. +All planned milestone tasks for the current release are completed! The focus is now on community feedback, bug hunting, and refining performance. ---- - -## Feedback & Contributions - -Found a bug or have an idea? [Open an issue](https://github.com/AlexTkDev/MacOSCleaner/issues) — contributions are welcome. +- 💬 **Share feedback & ideas:** Join the conversation in **[GitHub Discussions](https://github.com/AlexTkDev/MacOSCleaner/discussions)** (or **[Discussion #12](https://github.com/AlexTkDev/MacOSCleaner/discussions/12)**). +- 🐛 **Report bugs:** Ran into an issue or edge case? Please **[open an issue](https://github.com/AlexTkDev/MacOSCleaner/issues)** with details or logs. +- 📖 **Documentation & Guides:** For FAQs and in-depth articles, visit the **[MacOSCleaner Wiki](https://github.com/AlexTkDev/MacOSCleaner/wiki)**. -For detailed documentation, user guides, and FAQs, visit the 📖 [MacOSCleaner Wiki](https://github.com/AlexTkDev/MacOSCleaner/wiki). +Contributions are welcome! > *Note: All contributions are subject to the project's [Contributor License Agreement (CLA)](CLA.md) to maintain dual-licensing capabilities.* diff --git a/assets/screenshots/About_v2_1.png b/assets/screenshots/About_v2_1.png deleted file mode 100644 index c44f346..0000000 Binary files a/assets/screenshots/About_v2_1.png and /dev/null differ diff --git a/assets/screenshots/About_v2_1_1.png b/assets/screenshots/About_v2_1_1.png new file mode 100644 index 0000000..e00ac20 Binary files /dev/null and b/assets/screenshots/About_v2_1_1.png differ diff --git a/assets/screenshots/Settings_General_v2_1.png b/assets/screenshots/Settings_General_v2_1.png deleted file mode 100644 index e5e8858..0000000 Binary files a/assets/screenshots/Settings_General_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_General_v2_1_1.png b/assets/screenshots/Settings_General_v2_1_1.png new file mode 100644 index 0000000..fb296e2 Binary files /dev/null and b/assets/screenshots/Settings_General_v2_1_1.png differ