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
6 changes: 6 additions & 0 deletions Sources/AskKeyAppKit/App/AgentApprovalPanelActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Window operations kept separate from request selection and authorization.
@MainActor
struct AgentApprovalPanelActions {
let bringForward: () -> Void
let dismiss: () -> Void
}
28 changes: 28 additions & 0 deletions Sources/AskKeyAppKit/App/AgentApprovalPanelPlacement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import AppKit

enum AgentApprovalPanelPlacement {
static func screenFrame(pointer: NSPoint, screens: [NSRect], keyWindowScreen: NSRect?, mainScreen: NSRect?) -> NSRect? {
screens.first { $0.contains(pointer) } ?? keyWindowScreen ?? mainScreen
}

static func centeredOrigin(size: NSSize, visibleFrame: NSRect) -> NSPoint {
NSPoint(
x: visibleFrame.midX - size.width / 2,
y: min(max(visibleFrame.midY - size.height / 2, visibleFrame.minY), visibleFrame.maxY - size.height)
)
}

@MainActor
static func bringForward(_ panel: NSPanel) {
let screens = NSScreen.screens
let frame = screenFrame(
pointer: NSEvent.mouseLocation, screens: screens.map(\.frame),
keyWindowScreen: NSApp.keyWindow?.screen?.frame, mainScreen: NSScreen.main?.frame
)
let screen = screens.first { $0.frame == frame }
if let visible = screen?.visibleFrame {
panel.setFrameOrigin(centeredOrigin(size: panel.frame.size, visibleFrame: visible))
}
panel.makeKeyAndOrderFront(nil)
}
}
123 changes: 123 additions & 0 deletions Sources/AskKeyAppKit/App/AgentApprovalPresentationCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import AskKeyBroker

/// Owns the presentation queue independently of AppKit window construction.
@MainActor
final class AgentApprovalPresentationCoordinator {
private let screenState: () -> AgentApprovalScreenState
private let loadPending: () -> [BrokerPendingApproval]
private let clock: () -> Date
private let refreshExpiration: () -> Void
private let lockedReminder: (String, String) -> Void
private let feedback: (String) -> Void
private let present: (BrokerPendingApproval, BrokerApprovalDecision?, @escaping (BrokerApprovalDecision?) -> Void) -> AgentApprovalPanelActions
private let applyDecision: (BrokerPendingApproval, BrokerApprovalDecision) -> Void
private var activeRequest: BrokerPendingApproval?
private var presentationID: UUID?
private var panel: AgentApprovalPanelActions?
private var applyingDecision = false
private var deferredRequestIDs: Set<String> = []
var isPresenting: Bool { presentationID != nil }

init(
screenState: @escaping () -> AgentApprovalScreenState,
loadPending: @escaping () -> [BrokerPendingApproval],
clock: @escaping () -> Date = Date.init,
refreshExpiration: @escaping () -> Void,
lockedReminder: @escaping (String, String) -> Void,
feedback: @escaping (String) -> Void,
present: @escaping (BrokerPendingApproval, BrokerApprovalDecision?, @escaping (BrokerApprovalDecision?) -> Void) -> AgentApprovalPanelActions,
applyDecision: @escaping (BrokerPendingApproval, BrokerApprovalDecision) -> Void
) {
self.screenState = screenState
self.loadPending = loadPending
self.clock = clock
self.refreshExpiration = refreshExpiration
self.lockedReminder = lockedReminder
self.feedback = feedback
self.present = present
self.applyDecision = applyDecision
}

func presentPendingApproval(
operationID: String? = nil,
cancelledAuthenticationDecision: BrokerApprovalDecision? = nil,
userInitiated: Bool = false
) {
let requests: [BrokerPendingApproval]
switch AgentApprovalPrivacyPolicy.gatedRequest(screenState: screenState(), load: loadPending) {
case .lockedReminder(let title, let body):
if !applyingDecision { dismissActivePanel() }
lockedReminder(title, body)
if userInitiated { feedback("Unlock your Mac to review a pending request.") }
return
case .detailed(let loaded):
requests = (loaded ?? []).filter { $0.expiresAt.map { $0 > clock() } != false }
}
// Authentication/commit may still be running after the panel closes.
guard !applyingDecision else { return }
deferredRequestIDs.formIntersection(Set(requests.map(\.requestID)))
if let activeRequest {
let stillPending = requests.contains { $0.requestID == activeRequest.requestID && $0.capability == activeRequest.capability }
if stillPending, operationID == nil || operationID == activeRequest.request.operationID {
if userInitiated { panel?.bringForward() }
return
}
// Invalidate the old completion before closing its panel. It must
// never apply a decision to a newly selected request.
dismissActivePanel()
}
let eligible = userInitiated || operationID != nil
? requests
: requests.filter { !deferredRequestIDs.contains($0.requestID) }
guard let pending = AgentApprovalRequestSelection.select(eligible, operationID: operationID) else {
if userInitiated { feedback("This request is no longer pending.") }
return
}
deferredRequestIDs.remove(pending.requestID)
activeRequest = pending
let id = UUID()
presentationID = id
let actions = present(pending, cancelledAuthenticationDecision) { [weak self] decision in
self?.finish(decision, pending: pending, presentationID: id)
}
if presentationID == id, !applyingDecision { panel = actions }
}

func decisionFinished() {
activeRequest = nil
presentationID = nil
panel = nil
applyingDecision = false
}

func refreshAfterResume() {
// Refresh badges and terminal records even while the privacy gate is
// closed; no request details need to be loaded to expire them.
refreshExpiration()
presentPendingApproval()
}

private func finish(_ decision: BrokerApprovalDecision?, pending: BrokerPendingApproval, presentationID id: UUID) {
guard presentationID == id, !applyingDecision else { return }
panel = nil
let valid = screenState() == .unlocked
&& pending.expiresAt.map { $0 > clock() } != false
if let decision, valid, loadPending().contains(where: {
$0.requestID == pending.requestID && $0.capability == pending.capability
}) {
applyingDecision = true
applyDecision(pending, decision)
return
}
decisionFinished()
if valid { deferredRequestIDs.insert(pending.requestID) }
presentPendingApproval()
}

private func dismissActivePanel() {
let previous = panel
decisionFinished()
previous?.dismiss()
}
}
88 changes: 48 additions & 40 deletions Sources/AskKeyAppKit/App/AppDelegate+Approval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,56 +30,63 @@ extension AppDelegate {
forName: .presentNextAgentApproval, object: nil, queue: .main
) { [weak self] note in
MainActor.assumeIsolated {
self?.presentPendingApproval(operationID: note.object as? String)
self?.presentPendingApproval(operationID: note.object as? String, userInitiated: true)
}
}
screenUnlockObserver = DistributedNotificationCenter.default.addObserver(
forName: .init("com.apple.screenIsUnlocked"), object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.resetLockedApprovalReminder()
self?.presentPendingApproval()
self?.approvalPresentation.refreshAfterResume()
}
}
approvalWakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.approvalPresentation.refreshAfterResume() }
}
}

private func presentPendingApproval(
operationID: String? = nil,
cancelledAuthenticationDecision: BrokerApprovalDecision? = nil
cancelledAuthenticationDecision: BrokerApprovalDecision? = nil,
userInitiated: Bool = false
) {
guard !presentingApproval else { return }
let pending: BrokerPendingApproval
switch AgentApprovalPrivacyPolicy.gatedRequest(
screenState: Self.screenState(),
load: {
AgentApprovalRequestSelection.select(
Vault.shared.approvalRequests.pendingRequests(),
operationID: operationID
approvalPresentation.presentPendingApproval(
operationID: operationID,
cancelledAuthenticationDecision: cancelledAuthenticationDecision,
userInitiated: userInitiated
)
}

func makeApprovalPresentationCoordinator() -> AgentApprovalPresentationCoordinator {
AgentApprovalPresentationCoordinator(
screenState: { Self.screenState() },
loadPending: { Vault.shared.approvalRequests.pendingRequests() },
refreshExpiration: { Vault.shared.approvalRequests.refreshExpiration() },
lockedReminder: { [weak self] title, body in
self?.postLockedApprovalReminder(title: title, body: body)
},
feedback: { [weak self] message in self?.vault.errorMessage = message },
present: { [weak self] pending, cancelledDecision, completion in
guard let self else { return AgentApprovalPanelActions(bringForward: {}, dismiss: {}) }
self.resetLockedApprovalReminder()
return self.runFrozenApprovalPanel(
request: pending.request,
expiresAt: pending.expiresAt,
pending: pending,
cancelledAuthenticationDecision: cancelledDecision,
completion: completion
)
},
applyDecision: { [weak self] pending, decision in
self?.applyApprovalDecision(pending: pending, decision: decision)
}
) {
case .lockedReminder(let title, let body):
postLockedApprovalReminder(title: title, body: body)
return
case .detailed(let loaded):
guard let loaded else { return }
pending = loaded
resetLockedApprovalReminder()
}
presentingApproval = true
)
}

let request = pending.request
runFrozenApprovalPanel(
request: request,
expiresAt: pending.expiresAt,
pending: pending,
cancelledAuthenticationDecision: cancelledAuthenticationDecision
) { [weak self] decision in
guard let self else { return }
guard let decision else {
self.presentingApproval = false
return
}
private func applyApprovalDecision(pending: BrokerPendingApproval, decision: BrokerApprovalDecision) {
let fileWrites = self.fileWriteCoordinator
let applyDecision: @Sendable () -> Void = { [weak self, fileWrites] in
var didFail = false
Expand Down Expand Up @@ -109,7 +116,7 @@ extension AppDelegate {
let cancelled = authenticationCancelled
Task { @MainActor [weak self] in
guard let self else { return }
self.presentingApproval = false
self.approvalPresentation.decisionFinished()
if cancelled {
self.presentPendingApproval(
operationID: pending.request.operationID,
Expand All @@ -120,7 +127,7 @@ extension AppDelegate {
}
// The retried request may have expired meanwhile; never leave
// other requests waiting without a prompt.
if !self.presentingApproval, !Vault.shared.approvalRequests.pendingRequests().isEmpty {
if !self.approvalPresentation.isPresenting, !Vault.shared.approvalRequests.pendingRequests().isEmpty {
self.presentPendingApproval()
}
}
Expand All @@ -130,7 +137,6 @@ extension AppDelegate {
} else {
DispatchQueue.global(qos: .userInitiated).async(execute: applyDecision)
}
}
}

private func runFrozenApprovalPanel(
Expand All @@ -139,7 +145,7 @@ extension AppDelegate {
pending: BrokerPendingApproval? = nil,
cancelledAuthenticationDecision: BrokerApprovalDecision? = nil,
completion: @escaping @MainActor (BrokerApprovalDecision?) -> Void = { _ in }
) {
) -> AgentApprovalPanelActions {
var finished = false
var privacyTimer: Timer?
var resizeObserver: NSObjectProtocol?
Expand Down Expand Up @@ -238,7 +244,6 @@ extension AppDelegate {
hosting.sizingOptions = [.preferredContentSize]
panel.contentViewController = hosting
panel.setContentSize(hosting.view.fittingSize)
panel.center()
// Expanding Details makes the card taller; keep its buttons on screen.
resizeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResizeNotification, object: panel, queue: .main
Expand All @@ -255,13 +260,16 @@ extension AppDelegate {
} ?? false
if Self.screenState() != .unlocked || expiresAt.map({ $0 <= Date() }) == true || requestEnded {
finish(nil)
if Self.screenState() == .unlocked { self.presentPendingApproval() }
}
}
}
if let privacyTimer { RunLoop.main.add(privacyTimer, forMode: .common) }
panel.level = .modalPanel
panel.makeKeyAndOrderFront(nil)
AgentApprovalPanelPlacement.bringForward(panel)
return AgentApprovalPanelActions(
bringForward: { AgentApprovalPanelPlacement.bringForward(panel) },
dismiss: { finish(nil) }
)
}

/// Moves the panel just enough to fit the screen's visible frame, keeping
Expand Down
3 changes: 3 additions & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+Termination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ extension AppDelegate {
if let screenUnlockObserver {
DistributedNotificationCenter.default.removeObserver(screenUnlockObserver)
}
if let approvalWakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(approvalWakeObserver)
}
Vault.shared.cleanupRuntimeFileDeliveries()
}
}
3 changes: 2 additions & 1 deletion Sources/AskKeyAppKit/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
var statusItemMenuMonitor: Any?
var approvalPresentationObserver: NSObjectProtocol?
var screenUnlockObserver: NSObjectProtocol?
var approvalWakeObserver: NSObjectProtocol?
var recycleBinCleanupTimer: Timer?
var lockedApprovalReminderPosted = false
var lockedApprovalReminderAttempt: UUID?
Expand All @@ -30,7 +31,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
var brokerServer: BrokerSocketServer?
var fileWriteCoordinator: BrokerFileWriteCoordinator?
@Published internal(set) var pendingApprovalCount = 0
var presentingApproval = false
lazy var approvalPresentation = makeApprovalPresentationCoordinator()
var launchSource = AppLaunchSource.active
var managementDockPolicy = ManagementDockPolicy()
var dockPolicyApplicationScheduled = false
Expand Down
2 changes: 2 additions & 0 deletions Sources/AskKeyAppKit/AppLanguage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ enum AppLanguage {
"Keep Launch at Login",
"Ask Key has pending requests",
"Unlock your Mac to review a pending request.",
"This request is no longer pending.",
"Expired without decision",
"Open Ask Key to review pending requests.",
"Disable system authentication for read approvals",
"Ask Key could not save an access record. Credential operations continue, and this warning will remain until recording succeeds.",
Expand Down
Loading
Loading