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
10 changes: 7 additions & 3 deletions Sources/Clavis/Components/LibrarySidebarComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions Sources/Clavis/State/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions Sources/Clavis/State/RequestPauseModel.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 14 additions & 6 deletions Sources/Clavis/Views/KeyListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.")
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
19 changes: 18 additions & 1 deletion Sources/Clavis/Views/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading
Loading