diff --git a/SECURITY.md b/SECURITY.md index 658b06b..677ffc1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -96,7 +96,7 @@ UserDefaults. Please do not open a public issue for security problems. -1. Email **security@osmancagrigenc.dev** with a description, the affected version, steps to +1. Email **info@codifya.com** with a description, the affected version, steps to reproduce and the impact you expect. 2. We aim to acknowledge reports within 48 hours and to agree on a fix timeline with you. diff --git a/Sources/MacOptimizer/App/AppState.swift b/Sources/MacOptimizer/App/AppState.swift index 9862aed..3ca54c5 100644 --- a/Sources/MacOptimizer/App/AppState.swift +++ b/Sources/MacOptimizer/App/AppState.swift @@ -151,7 +151,9 @@ public final class AppState: ObservableObject { // Alert / Notification State @Published public var activeAlertMessage: String? @Published public var showAlert = false - @Published public var aiKillConfirmationPID: Int32? + /// PID awaiting the user's explicit confirmation before it is sent SIGTERM. Every UI path that + /// terminates a process except the Memory screen's own force-quit dialog goes through this. + @Published public var pendingTerminationPID: Int32? public var unresolvedAlertsCount: Int { autonomousAlerts.filter { !$0.isResolved }.count @@ -414,14 +416,24 @@ public final class AppState: ObservableObject { } case .killProcess: if let pid = action.targetPID { - aiKillConfirmationPID = pid + requestProcessTermination(pid: pid) } } } - public func confirmAIProcessTermination() { - guard let pid = aiKillConfirmationPID else { return } - aiKillConfirmationPID = nil + /// Asks the user to confirm terminating `pid`; nothing is terminated until they confirm. + public func requestProcessTermination(pid: Int32) { + pendingTerminationPID = pid + } + + public func cancelProcessTermination() { + pendingTerminationPID = nil + } + + /// Sends SIGTERM (never SIGKILL) to the process the user just confirmed. + public func confirmProcessTermination() { + guard let pid = pendingTerminationPID else { return } + pendingTerminationPID = nil killProcess(pid: pid, force: false) } diff --git a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift index 99c243f..8be4996 100644 --- a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift +++ b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift @@ -162,7 +162,8 @@ public struct CLICommandRunner { status Print current CPU, memory, thermal, swap, and disk telemetry. clean Scan for junk files (default: --dry-run). clean --execute --yes Print the plan and move approved items to Trash. - --include-trash Include Trash items in the plan (requires extra confirmation). + --include-trash Also list Trash items; with --execute --yes, permanently delete them + (no confirmation beyond --yes). version Print version and license information. help Show this help menu. diff --git a/Sources/MacOptimizer/Views/AI/AICopilotView.swift b/Sources/MacOptimizer/Views/AI/AICopilotView.swift index bd8c214..9687bce 100644 --- a/Sources/MacOptimizer/Views/AI/AICopilotView.swift +++ b/Sources/MacOptimizer/Views/AI/AICopilotView.swift @@ -38,15 +38,6 @@ public struct AICopilotView: View { .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(NSColor.windowBackgroundColor).opacity(0.5)) - .alert(L10n.string("Terminate this process?", table: .ai), isPresented: Binding( - get: { appState.aiKillConfirmationPID != nil }, - set: { if !$0 { appState.aiKillConfirmationPID = nil } } - )) { - Button("Cancel", role: .cancel) { appState.aiKillConfirmationPID = nil } - Button("Terminate Normally", role: .destructive) { appState.confirmAIProcessTermination() } - } message: { - Text(L10n.string("PID: %lld. SIGTERM will be sent first.", table: .ai, Int64(appState.aiKillConfirmationPID ?? 0))) - } .onAppear { if appState.aiInsights.isEmpty { appState.runAIHealthAnalysis() diff --git a/Sources/MacOptimizer/Views/Dashboard/DashboardView.swift b/Sources/MacOptimizer/Views/Dashboard/DashboardView.swift index 91da03a..128faa0 100644 --- a/Sources/MacOptimizer/Views/Dashboard/DashboardView.swift +++ b/Sources/MacOptimizer/Views/Dashboard/DashboardView.swift @@ -212,7 +212,7 @@ public struct DashboardView: View { LiveTopProcessesCard( metrics: appState.metrics, onKill: { pid in - appState.killProcess(pid: pid) + appState.requestProcessTermination(pid: pid) }, onNavigateToMemory: { appState.selectedTab = .memory diff --git a/Sources/MacOptimizer/Views/MainView.swift b/Sources/MacOptimizer/Views/MainView.swift index e0958ca..01653c8 100644 --- a/Sources/MacOptimizer/Views/MainView.swift +++ b/Sources/MacOptimizer/Views/MainView.swift @@ -26,6 +26,20 @@ public struct MainView: View { dismissButton: .default(Text(l10n: "Done")) ) } + // Shared confirmation for the dashboard, AI Copilot and Autonomous Guard terminate buttons. + .alert(L10n.string("Terminate this process?", table: .ai), isPresented: Binding( + get: { appState.pendingTerminationPID != nil }, + set: { if !$0 { appState.cancelProcessTermination() } } + )) { + Button(role: .cancel) { appState.cancelProcessTermination() } label: { + Text(l10n: "Cancel", table: .ai) + } + Button(role: .destructive) { appState.confirmProcessTermination() } label: { + Text(l10n: "Terminate Normally", table: .ai) + } + } message: { + Text(L10n.string("PID: %lld. SIGTERM will be sent first.", table: .ai, Int64(appState.pendingTerminationPID ?? 0))) + } } // MARK: - Sidebar diff --git a/Tests/MacOptimizerTests/MacOptimizerTests.swift b/Tests/MacOptimizerTests/MacOptimizerTests.swift index 82d769d..8cf194c 100644 --- a/Tests/MacOptimizerTests/MacOptimizerTests.swift +++ b/Tests/MacOptimizerTests/MacOptimizerTests.swift @@ -844,6 +844,25 @@ final class MacOptimizerTests: XCTestCase { XCTAssertFalse(CLICommandRunner.helpText.contains { "çğıİöşüÇĞÖŞÜ".contains($0) }) } + func testCLIHelpDoesNotPromiseExtraTrashConfirmation() { + XCTAssertFalse(CLICommandRunner.helpText.contains("extra confirmation")) + XCTAssertTrue(CLICommandRunner.helpText.contains("no confirmation beyond --yes")) + } + + @MainActor + func testProcessTerminationWaitsForExplicitConfirmation() { + let state = AppState(startMonitoring: false) + state.requestProcessTermination(pid: 4242) + XCTAssertEqual(state.pendingTerminationPID, 4242) + state.cancelProcessTermination() + XCTAssertNil(state.pendingTerminationPID) + + state.executeAIAction(AIAction(title: "Quit", type: .killProcess, targetPID: 4343)) + XCTAssertEqual(state.pendingTerminationPID, 4343, "AI/watchdog kill actions must only request confirmation") + state.cancelProcessTermination() + XCTAssertNil(state.pendingTerminationPID) + } + func testBatteryHealthUsesAppleSiliconCapacityAndIntelCapacityShapes() { let appleSilicon = ["AppleRawMaxCapacity": 4_600, "NominalChargeCapacity": 4_550, "DesignCapacity": 5_000] let intel = ["MaxCapacity": 4_000, "DesignCapacity": 5_000]