diff --git a/Sources/Clavis/Components/LibrarySidebarComponents.swift b/Sources/Clavis/Components/LibrarySidebarComponents.swift index 92b33b8..2f55580 100644 --- a/Sources/Clavis/Components/LibrarySidebarComponents.swift +++ b/Sources/Clavis/Components/LibrarySidebarComponents.swift @@ -107,14 +107,15 @@ struct LibrarySearchField: NSViewRepresentable { struct LibraryAgentStatus: View { let active: Bool + var requestState: RequestPauseModel.State = .accepting var body: some View { HStack(spacing: 7) { Circle() - .fill(active ? Color.green : Color.secondary) + .fill(active && requestState == .accepting ? Color.green : Color.secondary) .frame(width: 6, height: 6) .accessibilityHidden(true) - Text(active ? "Agent active" : "Agent unavailable") + Text(!active ? "Agent unavailable" : requestState == .paused ? "Requests paused" : requestState == .unknown ? "Agent status unavailable" : "Agent active") .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -131,6 +132,8 @@ struct LibraryMoreMenu: View { let settings: () -> Void let lock: () -> Void let revoke: () -> Void + var paused = false + var busy = false var body: some View { Menu { @@ -145,8 +148,9 @@ struct LibraryMoreMenu: View { Label("Lock Clavis", systemImage: "lock") } Button(role: .destructive, action: revoke) { - Label("Revoke All Sessions…", systemImage: "stop.circle") + Label(paused ? "Resume Requests…" : "Activate Kill Switch…", systemImage: paused ? "play.circle" : "stop.circle") } + .disabled(busy) Divider() Link(destination: URL(string: "https://github.com/GDR/clavis#readme")!) { Label("Help & Documentation", systemImage: "questionmark.circle") diff --git a/Sources/Clavis/State/AppState.swift b/Sources/Clavis/State/AppState.swift index 3ca0ac1..6342fdc 100644 --- a/Sources/Clavis/State/AppState.swift +++ b/Sources/Clavis/State/AppState.swift @@ -159,6 +159,7 @@ public class AppState: ObservableObject { public let keyManagementViewModel: KeyManagementViewModel public let agentSessionViewModel: AgentSessionViewModel public let settingsViewModel: SettingsViewModel + public let requestPolicy: RequestPauseModel private var cancellables = Set() @@ -203,6 +204,7 @@ public class AppState: ObservableObject { terminationAgentStop: terminationAgentStop ) self.settingsViewModel = settingsVM + self.requestPolicy = RequestPauseModel(agent: agentLifecycle) // Initialize stored properties from child viewmodels self.keys = keyVM.keys @@ -221,6 +223,10 @@ public class AppState: ObservableObject { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } + requestPolicy.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + }.store(in: &cancellables) + // Combine Upward Synchronization: Child -> AppState keyVM.$keys .dropFirst() @@ -408,6 +414,13 @@ public class AppState: ObservableObject { agentSessionViewModel.extendAgentSession(id: id, minutes: minutes) } + public func setRequestsPaused(_ paused: Bool) async { + _ = await requestPolicy.setPaused(paused) + errorMessage = requestPolicy.errorMessage + refreshAgentSessions() + updateGitGraceState() + } + public func endGitSigningSession() { settingsViewModel.endGitSigningSession() self.errorMessage = settingsViewModel.errorMessage diff --git a/Sources/Clavis/State/RequestPauseModel.swift b/Sources/Clavis/State/RequestPauseModel.swift new file mode 100644 index 0000000..5a08539 --- /dev/null +++ b/Sources/Clavis/State/RequestPauseModel.swift @@ -0,0 +1,66 @@ +import Foundation +import Combine +import ClavisCore + +/// Presentation follows acknowledged daemon policy, never a local pause preference. +@MainActor +public final class RequestPauseModel: ObservableObject { + public enum State: Equatable { case unknown, accepting, paused } + @Published public private(set) var state: State = .unknown + @Published public private(set) var isBusy = false + @Published public private(set) var errorMessage: String? + private let agent: any AgentControlling + + public init(agent: any AgentControlling) { self.agent = agent } + public var isPaused: Bool { state == .paused } + public var status: String { + switch state { + case .unknown: return "Agent status unavailable" + case .accepting: return "Agent active" + case .paused: return "Requests paused" + } + } + + public func refresh() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + let agent = agent + let snapshot = await Task.detached { agent.applicationRegistry() }.value + apply(snapshot) + } + + /// Pause revokes approvals immediately; resume requires daemon-side fresh authentication. + /// Neither path invents a successful state when the control service is unavailable. + @discardableResult + public func setPaused(_ paused: Bool) async -> Bool { + guard !isBusy else { return false } + if state == (paused ? .paused : .accepting) { return true } + isBusy = true + errorMessage = nil + defer { isBusy = false } + let agent = agent + let result = await Task.detached { + let accepted = agent.changeApplicationPolicy(paused ? .pause : .resume) + return (accepted, agent.applicationRegistry()) + }.value + apply(result.1) + guard result.0 else { + errorMessage = "The request policy change was not confirmed. Check the agent and try again." + return false + } + guard let snapshot = result.1, snapshot.isPaused == paused else { + errorMessage = "The agent accepted the change, but its current request policy could not be confirmed." + return false + } + return true + } + + private func apply(_ snapshot: ApplicationRegistrySnapshot?) { + guard let snapshot else { + state = .unknown + return + } + state = snapshot.isPaused ? .paused : .accepting + } +} diff --git a/Sources/Clavis/Views/KeyListView.swift b/Sources/Clavis/Views/KeyListView.swift index 5ea95b6..30ed3c2 100644 --- a/Sources/Clavis/Views/KeyListView.swift +++ b/Sources/Clavis/Views/KeyListView.swift @@ -314,7 +314,9 @@ struct KeyListView: View { history: { appState.openHistoryPane(forKeyFingerprint: nil) }, settings: { appState.activeSheet = .settings }, lock: { PanelLockController.shared.lock(reason: .lockNow) }, - revoke: { confirmingRevokeAll = true } + revoke: { confirmingRevokeAll = true }, + paused: appState.requestPolicy.isPaused, + busy: appState.requestPolicy.isBusy ) } .padding(.horizontal, 12) @@ -328,12 +330,18 @@ struct KeyListView: View { .padding(.horizontal, 12) .padding(.bottom, 8) } - .confirmationDialog("Revoke all active sessions?", isPresented: $confirmingRevokeAll) { - Button("Revoke All Sessions", role: .destructive) { - appState.endGitSigningSession() + .task { await appState.requestPolicy.refresh() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + Task { await appState.requestPolicy.refresh() } + } + .confirmationDialog(appState.requestPolicy.isPaused ? "Resume requests?" : "Activate kill switch?", isPresented: $confirmingRevokeAll) { + Button(appState.requestPolicy.isPaused ? "Resume Requests" : "Activate Kill Switch", role: .destructive) { + Task { await appState.setRequestsPaused(!appState.requestPolicy.isPaused) } } } message: { - Text("New requests will require approval. Existing SSH connections may remain open.") + Text(appState.requestPolicy.isPaused + ? "Resuming requires authentication. Revoked approvals will not be restored." + : "Revoke all approvals and pause new requests. Existing SSH connections may remain open.") } } @@ -395,7 +403,7 @@ struct KeyListView: View { .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider().padding(.horizontal, 14) - LibraryAgentStatus(active: appState.isSocketActive) + LibraryAgentStatus(active: appState.isSocketActive, requestState: appState.requestPolicy.state) } } } diff --git a/Sources/Clavis/Views/MenuBarView.swift b/Sources/Clavis/Views/MenuBarView.swift index d5ec764..22f3bc3 100644 --- a/Sources/Clavis/Views/MenuBarView.swift +++ b/Sources/Clavis/Views/MenuBarView.swift @@ -6,6 +6,7 @@ import ClavisCore public struct MenuBarView: View { @EnvironmentObject var appState: AppState @ObservedObject var lock: PanelLockController + @State private var confirmingRequestPolicy = false public init(lock: PanelLockController? = nil) { self.lock = lock ?? .shared @@ -66,6 +67,12 @@ public struct MenuBarView: View { separator + MenuBarActionItem( + title: appState.requestPolicy.isPaused ? "Resume Requests…" : "Activate Kill Switch…", + icon: appState.requestPolicy.isPaused ? "play.circle" : "stop.circle" + ) { confirmingRequestPolicy = true } + .disabled(appState.requestPolicy.isBusy) + MenuBarActionItem( title: appState.isSocketActive ? ClavisUIStrings.MenuBar.restartAgent @@ -113,6 +120,16 @@ public struct MenuBarView: View { } ) .onAppear { appState.refresh() } + .task { await appState.requestPolicy.refresh() } + .confirmationDialog(appState.requestPolicy.isPaused ? "Resume requests?" : "Activate kill switch?", isPresented: $confirmingRequestPolicy) { + Button(appState.requestPolicy.isPaused ? "Resume Requests" : "Activate Kill Switch", role: .destructive) { + Task { await appState.setRequestsPaused(!appState.requestPolicy.isPaused) } + } + } message: { + Text(appState.requestPolicy.isPaused + ? "Authentication is required. Revoked approvals will not be restored." + : "Revoke approvals and pause new requests. Existing SSH connections may remain open.") + } .task { // Snapshots come from the agent; the one-second timeline only updates display. while !Task.isCancelled { @@ -138,7 +155,7 @@ public struct MenuBarView: View { .frame(width: 6, height: 6) .accessibilityHidden(true) Text(appState.isSocketActive - ? ClavisUIStrings.MenuBar.agentActive + ? appState.requestPolicy.status : ClavisUIStrings.MenuBar.agentOffline) .font(.system(size: 12)) .foregroundStyle(.secondary) diff --git a/Tests/ClavisAppTests/RequestPauseModelTests.swift b/Tests/ClavisAppTests/RequestPauseModelTests.swift new file mode 100644 index 0000000..307c824 --- /dev/null +++ b/Tests/ClavisAppTests/RequestPauseModelTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +@testable import Clavis +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite @MainActor struct RequestPauseModelTests { + @Test func unavailableIsNotReportedAsActive() async { + let model = RequestPauseModel(agent: MockAgentController()) + #expect(model.state == .unknown) + await model.refresh() + #expect(model.state == .unknown) + #expect(model.status == "Agent status unavailable") + #expect(!model.isBusy) + } + + @Test func restoredPersistentPauseIsShownAfterLaunch() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(model.state == .paused) + #expect(model.isPaused) + #expect(model.status == "Requests paused") + } + + @Test func pauseAndResumeFollowDaemonAcknowledgement() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + agent.applicationPolicyHandler = { change in + agent.stubbedApplicationRegistry = .init(requestsPaused: change == .pause) + return true + } + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(model.state == .accepting) + #expect(await model.setPaused(true)) + #expect(model.state == .paused) + #expect(await model.setPaused(false)) + #expect(model.state == .accepting) + #expect(agent.applicationPolicyChanges == [.pause, .resume]) + } + + @Test func cancelledResumeKeepsPauseAndReportsFailure() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(!(await model.setPaused(false))) + #expect(model.state == .paused) + #expect(model.errorMessage != nil) + #expect(agent.applicationPolicyChanges == [.resume]) + } + + @Test func lostReadbackNeverInventsSuccessfulResume() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + agent.applicationPolicyHandler = { _ in + agent.stubbedApplicationRegistry = nil + return true + } + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(!(await model.setPaused(false))) + #expect(model.state == .unknown) + #expect(model.errorMessage?.contains("could not be confirmed") == true) + } + + @Test func contradictoryReadbackUsesActualPolicy() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + agent.applicationPolicyHandler = { _ in true } + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(!(await model.setPaused(true))) + #expect(model.state == .accepting) + #expect(model.errorMessage != nil) + } + + @Test func sameStateDoesNotReauthenticateOrRestoreApprovals() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(await model.setPaused(true)) + #expect(agent.applicationPolicyChanges.isEmpty) + #expect(agent.endAgentSessionCallCount == 0) + } + + @Test func failedPauseCanReflectAnotherSuccessfulPause() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + agent.applicationPolicyHandler = { _ in + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + return false + } + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(!(await model.setPaused(true))) + #expect(model.state == .paused) + #expect(model.errorMessage != nil) + } + @Test func appStateConnectsPolicyAndSessionRefresh() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let manager = fixture.makeKeyManager() + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + agent.applicationPolicyHandler = { change in + agent.stubbedApplicationRegistry = .init(requestsPaused: change == .pause) + return true + } + let state = AppState(keyManager: manager, + sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager), + agentLifecycle: agent) + await state.requestPolicy.refresh() + await state.setRequestsPaused(true) + #expect(state.requestPolicy.isPaused) + #expect(state.errorMessage == nil) + await state.setRequestsPaused(false) + #expect(state.requestPolicy.state == .accepting) + #expect(agent.applicationPolicyChanges == [.pause, .resume]) + #expect(agent.startAgentCallCount == 0) + #expect(agent.extendAgentSessionCallCount == 0) + } + } + + @Test func concurrentChangesCannotStartDuplicateControlRequests() async throws { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + agent.applicationPolicyHandler = { _ in + entered.signal() + _ = release.wait(timeout: .now() + 5) + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + return true + } + let model = RequestPauseModel(agent: agent) + await model.refresh() + let first = Task { await model.setPaused(true) } + let didEnter = await Task.detached { entered.wait(timeout: .now() + 5) == .success }.value + defer { release.signal() } + #expect(didEnter) + #expect(model.isBusy) + #expect(!(await model.setPaused(false))) + await model.refresh() + #expect(model.isBusy) + release.signal() + #expect(await first.value) + #expect(agent.applicationPolicyChanges == [.pause]) + #expect(!model.isBusy) + } + + @Test func refreshAfterDaemonLossRemovesStaleActiveStatus() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init() + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(model.state == .accepting) + agent.stubbedApplicationRegistry = nil + await model.refresh() + #expect(model.state == .unknown) + #expect(model.status == "Agent status unavailable") + } + + @Test func explicitRetryAfterCancellationCanResume() async { + let agent = MockAgentController() + agent.stubbedApplicationRegistry = .init(requestsPaused: true) + let model = RequestPauseModel(agent: agent) + await model.refresh() + #expect(!(await model.setPaused(false))) + agent.applicationPolicyHandler = { _ in + agent.stubbedApplicationRegistry = .init(requestsPaused: false) + return true + } + #expect(await model.setPaused(false)) + #expect(model.errorMessage == nil) + #expect(agent.applicationPolicyChanges == [.resume, .resume]) + } + +}