mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-09-27 18:48:00 +02:00
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:
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user