Pending request view (#821)

* Splitting out auth context stuff in preparation for batch

* Add uncommitted authhandler rename

* Split out auth/nonauth paths

* Messy auth batching infra WIP

* Restrict connectionAcceptedNotifications to creating filehandle

* WIP

* WIP

* WIP

* WIP

* WIP

* Reenable multilineinfoview

* WIP

* WIP

* Fixing up tests

* WIP

* Return empty bind response on parse throw

* JSON project

* JSON Project

* WIP

* WIP

* Almost done

* Tests

* Cleanup test calls

* Fixme

* Cleanup

* Localized strings
This commit is contained in:
Max Goedjen
2026-09-20 19:22:52 -07:00
committed by GitHub
parent 43ee687232
commit 54e0d00d87
34 changed files with 1390 additions and 527 deletions
+119
View File
@@ -0,0 +1,119 @@
import Cocoa
import OSLog
import SecretKit
import SecureEnclaveSecretKit
import SmartCardSecretKit
import SecretAgentKit
import Brief
import Observation
import Common
import SwiftUI
import CertificateKit
@main
struct SecretAgent: App {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store())
return list
}()
@MainActor private let certificateStore: CertificateStore = CertificateStore()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let authenticationHandler = AuthenticationHandler()
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
@Environment(\.openWindow) var openWindow
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "App")
@SceneBuilder var body: some Scene {
MenuBarExtra(isInserted: .constant(false)) {
EmptyView()
} label: {
Image(systemName: "lock")
.task {
await notifier.registerPersistenceHandler {
try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1)
}
}
.task {
let socketController = SocketController(path: URL.socketPath)
let agent = Agent(
storeList: storeList,
certificateStore: certificateStore,
authenticationHandler: authenticationHandler,
witness: notifier
)
for await session in socketController.sessions {
Task {
do {
let inputParser = try await XPCAgentInputParser()
let hosts = try? await XPCHostsfileReader().read()
for await message in session.messages {
let request = try await inputParser.parse(data: message)
let agentResponse = await agent.handle(request: request, provenance: session.provenance, hosts: hosts)
try session.write(agentResponse)
}
} catch {
try? session.close()
}
}
}
}
.task {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
}
}
.task {
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: certificateStore)
try? certsMigrator.migrate()
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
try? publicKeyFileStoreController.generateCertificates(for: certificateStore.certificates, clear: true)
}
}
.task {
authenticationHandler.setPendingRequestHandler { @MainActor in
openWindow(value: PendingRequestsViewIdentifier())
}
}
.task {
notifier.prompt()
_ = withObservationTracking {
updater.update
} onChange: { [updater, notifier] in
Task {
guard !updater.currentVersion.isTestBuild else { return }
await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release)
}
}
}
}
}
WindowGroup(for: PendingRequestsViewIdentifier.self) { _ in
pendingView
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
@ViewBuilder
var pendingView: some View {
if !authenticationHandler.batchableRequests.isEmpty {
PendingRequestsView(authenticationHandler: authenticationHandler)
}
}
}
struct PendingRequestsViewIdentifier: Codable, Hashable {}
+21 -30
View File
@@ -5,6 +5,8 @@ import SecretKit
import SecretAgentKit
import Brief
typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void)
final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate()
@@ -15,6 +17,12 @@ final class Notifier: Sendable {
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
}
func registerPersistenceHandler(action: @escaping PersistAction) async {
let rawDurations = [
Measurement(value: 1, unit: UnitDuration.minutes),
Measurement(value: 5, unit: UnitDuration.minutes),
@@ -24,11 +32,9 @@ final class Notifier: Sendable {
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
var allPersistenceActions = [doNotPersistAction]
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value
@@ -43,16 +49,11 @@ final class Notifier: Sendable {
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
}
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate
Task {
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration)
}
}
var categories = await UNUserNotificationCenter.current().notificationCategories()
categories.insert(persistAuthenticationCategory)
UNUserNotificationCenter.current().setNotificationCategories(categories)
await notificationDelegate.state.setPersistenceState(options: identifiers, action: action)
}
func prompt() {
@@ -60,7 +61,7 @@ final class Notifier: Sendable {
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
}
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?) async {
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async {
await notificationDelegate.state.setPending(secret: secret, store: store)
let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent()
@@ -76,7 +77,7 @@ final class Notifier: Sendable {
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
if offerPersistence {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
}
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@@ -115,13 +116,8 @@ extension Notifier: SigningWitness {
) async throws {
}
func witness(
accessTo secret: AnySecret,
from store: AnySecretStore,
by provenance: SigningRequestProvenance,
target: SigningRequestTarget?
) async throws {
await notify(accessTo: secret, from: store, by: provenance, target: target)
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, target: SigningRequestTarget?, offerPersistence: Bool) async throws {
await notify(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: offerPersistence)
}
}
@@ -150,28 +146,24 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
fileprivate actor State {
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias IgnoreAction = (@Sendable (Release) async -> Void)
fileprivate var release: Release?
fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
func setPending(secret: AnySecret, store: AnySecretStore) {
pendingPersistableSecrets[secret.id.description] = secret
pendingPersistableStores[store.id.description] = store
}
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
func retrievePending(secretID: String, optionID: String) -> (AnySecret, TimeInterval)? {
guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else {
return nil
}
pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options)
return (secret, options)
}
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
@@ -219,13 +211,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
}
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else {
return
}
let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
await state.persistAction?(secret, store, persistOptions)
guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return }
try? await state.persistAction?(secret, persistOptions)
}
@@ -0,0 +1,202 @@
import SwiftUI
import SecretKit
import SecretAgentKit
import SmartCardSecretKit
import Common
struct PendingRequestsView: View {
private let authenticationHandler: any AuthenticationHandlerProtocol
@Environment(\.dismissWindow) var dismiss
init(authenticationHandler: some AuthenticationHandlerProtocol) {
self.authenticationHandler = authenticationHandler
}
var body: some View {
ScrollView {
Text(.pendingRequestDescription)
ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in
MultilineInfoView {
if let first = group.element.first {
HStack {
HStack {
Image(nsImage: .init(byReferencing: first.provenance.origin.iconURL!))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text(first.provenance.origin.displayName)
.font(.subheadline)
Text(first.secret.name)
.font(.headline)
switch first.target {
case .connection(let payload):
if let host = payload.host {
Text(.authContextConnectingToUsernameAndHost(username: payload.username, host: host))
.font(.caption2)
} else {
Text(.authContextConnectingToUnknownHost)
.font(.caption2)
}
case .signature(let payload):
Text(.authContextSigningForNamespace(namespace: payload.namespace))
.font(.caption2)
default:
EmptyView()
}
}
}
Spacer()
VStack {
Button(.pendingRequestsReviewBatchButton) {
Task {
try? await authenticationHandler.requestAuthentication(for: Set(group.element))
if authenticationHandler.batchableRequests.isEmpty {
dismiss()
}
}
}
.buttonBorderShape(.capsule)
.primaryButton()
}
}
}
} items: {
ForEach(Array(group.element.enumerated()), id: \.offset) { pending in
HStack {
Text(pending.element.provenance.date.formatted())
Spacer()
Button(.pendingRequestsReviewSingleButton) {
Task {
try? await authenticationHandler.requestAuthentication(for: [pending.element])
if authenticationHandler.batchableRequests.isEmpty {
dismiss()
}
}
}
.buttonBorderShape(.capsule)
.normalButton()
}
}
}
}
}
.safeAreaPadding(20)
}
}
private struct TestHandler: AuthenticationHandlerProtocol {
var batchableRequests: [[SignatureRequest]] = []
func requestAuthentication(for requests: Set<SignatureRequest>) async throws {
}
func persistAuthentication<SecretType>(secret: SecretType, forDuration duration: TimeInterval) async throws where SecretType : Secret {
}
func setPendingRequestHandler(_ handler: @escaping () async throws -> Void) {
}
func authenticatedContext(for request: SignatureRequest, context: any AuthenticationContextProtocol) async throws -> (any AuthenticationContextProtocol)? {
nil
}
}
#Preview {
if #available(macOS 26.0, *) {
ScrollView {
MultilineInfoView {
HStack {
HStack {
Image("ghostty")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text("Ghostty")
.font(.subheadline)
Text("GitHub")
.font(.headline)
Text("Authenticating git@github.com")
.font(.caption2)
}
}
Spacer()
VStack {
Button("Review as Batch") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glassProminent)
}
}
} items: {
ForEach(0..<2) { _ in
HStack {
Text("4:05 PM")
Spacer()
Button("Review") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glass)
}
}
}
MultilineInfoView {
HStack {
HStack {
Image("ghostty")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
VStack(alignment: .leading) {
Text("Ghostty")
.font(.subheadline)
Text("Git Signing")
.font(.headline)
Text("Git Signature")
.font(.caption2)
}
}
Spacer()
VStack {
Button("Review as Batch") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glassProminent)
}
}
} items: {
ForEach(0..<2) { _ in
HStack {
Text("4:05 PM")
Spacer()
Button("Review") {
}
.buttonBorderShape(.capsule)
.buttonStyle(.glass)
}
}
}
}
.padding()
.formStyle(.grouped)
.frame(minHeight: 700)
}
}