Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions MacOSCleaner/Domains/Cleanup/SystemMaintenanceService.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
2 changes: 1 addition & 1 deletion MacOSCleaner/Features/About/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,5 @@ struct AboutView: View {
}

#Preview {
AboutView(availableUpdate: "2.1.0")
AboutView(availableUpdate: "2.1.1")
}
145 changes: 143 additions & 2 deletions MacOSCleaner/Features/Settings/SettingsGeneralView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,38 @@ 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
}
.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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions MacOSCleaner/Features/Settings/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion MacOSCleaner/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,6 @@ struct SettingsView: View {
settings: AppSettings(),
permissionsManager: PermissionsManager(),
onForget: {},
availableUpdate: .constant("2.1.0")
availableUpdate: .constant("2.1.1")
)
}
Loading
Loading