diff --git a/Sources/Packages/Resources/Localizable.xcstrings b/Sources/Packages/Resources/Localizable.xcstrings index e10998b..7e72ecf 100644 --- a/Sources/Packages/Resources/Localizable.xcstrings +++ b/Sources/Packages/Resources/Localizable.xcstrings @@ -365,16 +365,6 @@ }, "shouldTranslate" : false }, - "%@ - %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ - %2$@" - } - } - } - }, "about_build_log_button" : { "extractionState" : "manual", "localizations" : { @@ -6130,6 +6120,19 @@ }, "Certificates" : { + }, + "Connecting to %@@%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connecting to %1$@@%2$@" + } + } + } + }, + "Connecting to unknown host" : { + }, "copyable_click_to_copy_button" : { "extractionState" : "manual", @@ -20463,7 +20466,7 @@ "Review" : { }, - "Review All" : { + "Review as Batch" : { }, "secret_detail_certificate_path_label" : { @@ -24920,6 +24923,9 @@ } } } + }, + "Signing for %@" : { + }, "smart_card" : { "extractionState" : "manual", diff --git a/Sources/Packages/Sources/Common/ActionButtonStyle.swift b/Sources/Packages/Sources/Common/ActionButtonStyle.swift new file mode 100644 index 0000000..8db51f4 --- /dev/null +++ b/Sources/Packages/Sources/Common/ActionButtonStyle.swift @@ -0,0 +1,94 @@ +import SwiftUI + +public struct PrimaryButtonModifier: ViewModifier { + + @Environment(\.colorScheme) var colorScheme + @Environment(\.isEnabled) var isEnabled + + public func body(content: Content) -> some View { + // Tinted glass prominent is really hard to read on 26.0. + if #available(macOS 26.0, *), colorScheme == .dark, isEnabled { + content.buttonStyle(.glassProminent) + } else { + content.buttonStyle(.borderedProminent) + } + } + +} + +extension View { + + public func primaryButton() -> some View { + modifier(PrimaryButtonModifier()) + } + +} + +public struct ToolbarCircleButtonModifier: ViewModifier { + + public func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + .glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle) + } else { + content + .buttonStyle(.borderless) + } + } + +} + +extension View { + + public func toolbarCircleButton() -> some View { + modifier(ToolbarCircleButtonModifier()) + } + +} + +public struct NormalButtonModifier: ViewModifier { + + public func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.buttonStyle(.glass) + } else { + content.buttonStyle(.bordered) + } + } + +} + +extension View { + + public func normalButton() -> some View { + modifier(NormalButtonModifier()) + } + +} + +public struct DangerButtonModifier: ViewModifier { + + @Environment(\.colorScheme) var colorScheme + + public func body(content: Content) -> some View { + // Tinted glass prominent is really hard to read on 26.0. + if #available(macOS 26.0, *), colorScheme == .dark { + content.buttonStyle(.glassProminent) + .tint(.red) + .foregroundStyle(.white) + } else { + content.buttonStyle(.borderedProminent) + .tint(.red) + .foregroundStyle(.white) + } + } + +} + +extension View { + + public func danger() -> some View { + modifier(DangerButtonModifier()) + } + +} diff --git a/Sources/Packages/Sources/Common/MultilineInfoView.swift b/Sources/Packages/Sources/Common/MultilineInfoView.swift index 391b4bb..6eb35c8 100644 --- a/Sources/Packages/Sources/Common/MultilineInfoView.swift +++ b/Sources/Packages/Sources/Common/MultilineInfoView.swift @@ -97,6 +97,12 @@ public struct FixedTitleView: View { let subtitle: LocalizedStringResource? let image: Image + public init(title: LocalizedStringResource, subtitle: LocalizedStringResource?, image: Image) { + self.title = title + self.subtitle = subtitle + self.image = image + } + public var body: some View { HStack { image diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index 4bb3dc9..0cb77a9 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -185,7 +185,7 @@ extension Agent { } func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) async throws -> Data { - let context = try await authenticationHandler.waitForAuthentication(for: SignatureRequest(secret: secret, provenance: provenance)) + let context = try await authenticationHandler.waitForAuthentication(for: SignatureRequest(secret: secret, provenance: provenance, target: target)) let result = try await store.sign(data: data, with: secret, for: provenance, target: target, context: context.laContext) let signedData = signatureWriter.data(secret: secret, signature: result) try await witness?.witness(accessTo: secret, from: store, by: provenance, target: target, offerPersistence: false) // FIXME: THIS diff --git a/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift b/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift index bfa152f..89bf9f4 100644 --- a/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift +++ b/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift @@ -69,7 +69,7 @@ public final class AuthenticationContext: AuthenticationContextProtocol { private var persistedContexts: [AnySecret: AuthenticationContext] = [:] private var holdingRequests: Set = [] - private var activeTask: Task? + private var activeTask: Task? private var lastBatchAuthPresentation: Set? private var presentBatchAuth: (() async throws -> Void)? @@ -118,10 +118,15 @@ public final class AuthenticationContext: AuthenticationContextProtocol { activeTask = Task { logger.log("Beginning individual auth prompt") - _ = try? await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason) + let result = (try? await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason)) ?? false logger.log("Ended individual auth prompt") + return result + } + let result = try? await activeTask?.value + if result == false { + holdingRequests.remove(request) + return context } - _ = try? await activeTask?.value // TODO: Check something beyond cancellation? id? // Is this okay? Do we always assume that a cancelled task will be the proceeded on? if activeTask?.isCancelled ?? false { diff --git a/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift index 03117d3..9252ae1 100644 --- a/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift +++ b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift @@ -19,17 +19,22 @@ public struct SignatureRequest: Identifiable, Hashable, Sendable, Comparable { public let date: Date public let secret: AnySecret public let provenance: SigningRequestProvenance + public let target: SigningRequestTarget? - public init(secret: AnySecret, provenance: SigningRequestProvenance) { + public init(secret: AnySecret, provenance: SigningRequestProvenance, target: SigningRequestTarget?) { self.id = UUID() self.date = Date() self.secret = secret self.provenance = provenance + self.target = target } public var batchID: Int { var hasher = Hasher() provenance.batchID.hash(into: &hasher) + if let target { + target.batchID.hash(into: &hasher) + } secret.id.hash(into: &hasher) return hasher.finalize() } diff --git a/Sources/Packages/Sources/SecretKit/Types/SigningRequestTarget.swift b/Sources/Packages/Sources/SecretKit/Types/SigningRequestTarget.swift index f92ac84..eccad2b 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SigningRequestTarget.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SigningRequestTarget.swift @@ -2,12 +2,17 @@ import Foundation import AppKit /// Describes the target of the signature operation. -public enum SigningRequestTarget: Sendable { +public enum SigningRequestTarget: Sendable, Hashable { case connection(ConnectionPayload) case signature(SignaturePayload) - public struct ConnectionPayload: Sendable, Codable{ + public var batchID: Int { + hashValue + } + + + public struct ConnectionPayload: Sendable, Codable, Hashable { public let username: String public let hasSignature: Bool @@ -34,7 +39,7 @@ public enum SigningRequestTarget: Sendable { } - public struct SignaturePayload: Sendable, Codable { + public struct SignaturePayload: Sendable, Codable, Hashable { public let namespace: String public let hashAlgorithm: String diff --git a/Sources/SecretAgent/App.swift b/Sources/SecretAgent/App.swift index 3d98991..9ebffb7 100644 --- a/Sources/SecretAgent/App.swift +++ b/Sources/SecretAgent/App.swift @@ -66,25 +66,6 @@ struct SecretAgent: App { } } } -// .task { -// let socketController = SocketController(path: URL.agentHomeURL.appendingPathComponent("socket-two.ssh").path()) -// let socketController = SocketController(path: "/Users/max/Downloads/test.ssh") -// let agent = Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier) -// for await session in socketController.sessions { -// Task { -// let inputParser = try await XPCAgentInputParser() -// do { -// for await message in session.messages { -// let request = try await inputParser.parse(data: message) -// let agentResponse = await agent.handle(request: request, provenance: session.provenance) -// 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) { @@ -101,7 +82,7 @@ struct SecretAgent: App { } .task { authenticationHandler.setBatchAuthHandler { @MainActor in - openWindow(id: String(describing: BatchedRequestsView.self)) + openWindow(value: BatchedRequestsViewIdentifier()) } } @@ -119,7 +100,7 @@ struct SecretAgent: App { } } } - WindowGroup(id: String(describing: BatchedRequestsView.self)) { + WindowGroup(for: BatchedRequestsViewIdentifier.self) { _ in pendingView } .windowStyle(.hiddenTitleBar) @@ -135,3 +116,4 @@ struct SecretAgent: App { } +struct BatchedRequestsViewIdentifier: Codable, Hashable {} diff --git a/Sources/SecretAgent/BatchedRequestsView.swift b/Sources/SecretAgent/BatchedRequestsView.swift index e843900..2ba265c 100644 --- a/Sources/SecretAgent/BatchedRequestsView.swift +++ b/Sources/SecretAgent/BatchedRequestsView.swift @@ -13,44 +13,70 @@ struct BatchedRequestsView: View { } var body: some View { - VStack(alignment: .leading) { - Form { - Text("Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually.") - ForEach(Array(authenticationHandler.batchableRequests.enumerated()), id: \.offset) { group in - Section { - ForEach(Array(group.element.enumerated()), id: \.offset) { pending in + ScrollView { + Text("Multiple authenticated requests are pending. You can approve them batches, or request they all proceed individually.") + 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(pending.element.provenance.origin.displayName) + Text(first.provenance.origin.displayName) + .font(.subheadline) + Text(first.secret.name) .font(.headline) - Text(pending.element.provenance.date.formatted()) - .font(.footnote) - } - Spacer() - Button("Review") { - Task { - try? await authenticationHandler.requestAuthentication(for: [pending.element]) + switch first.target { + case .connection(let payload): + if let host = payload.host { + Text("Connecting to \(payload.username)@\(host)") + .font(.caption2) + } else { + Text("Connecting to unknown host") + .font(.caption2) + } + case .signature(let payload): + Text("Signing for \(payload.namespace)") + .font(.caption2) + default: + EmptyView() } } } - } - } header: { - HStack { - Text("\(group.element.first!.provenance.origin.displayName) - \(group.element.first!.secret.name)") Spacer() - Button("Review All") { - Task { - try? await authenticationHandler.requestAuthentication(for: Set(group.element)) + VStack { + Button("Review as Batch") { + Task { + try? await authenticationHandler.requestAuthentication(for: Set(group.element)) + } } - + .buttonBorderShape(.capsule) + .primaryButton() } } } + } items: { + ForEach(Array(group.element.enumerated()), id: \.offset) { pending in + HStack { + Text(pending.element.provenance.date.formatted()) + Spacer() + Button("Review") { + Task { + try? await authenticationHandler.requestAuthentication(for: [pending.element]) + } + } + .buttonBorderShape(.capsule) + .normalButton() + } + } + } } - .formStyle(.grouped) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaPadding(20) } } @@ -76,43 +102,94 @@ private struct TestHandler: AuthenticationHandlerProtocol { } } -// -//#Preview { -// ScrollView { -// MultilineInfoView(title: "GitHub", subtitle: "Ghostty", image: Image(systemName: "lock"), items: [ -// " -// ]) -//// Section { -//// ForEach(0..<2) { _ in -//// VStack(alignment: .leading) { -//// Text("Ghostty") -//// .font(.headline) -//// Text("zsh 􀯻 git 􀯻 zsh") -//// .font(.footnote) -//// Text("4:05 PM") -//// } -//// } -//// } header: { -//// Text("GitHub") -//// } -//// Section { -//// ForEach(0..<2) { _ in -//// VStack(alignment: .leading) { -//// Text("Ghostty") -//// .font(.headline) -//// Text("zsh 􀯻 git") -//// .font(.footnote.monospaced()) -//// Text("Git Signature") -//// .font(.footnote) -//// Text("4:05 PM") -//// .font(.caption) -//// } -//// } -//// } header: { -//// Text("GitHub Signing Key") -//// } -// } -// .padding() -// .formStyle(.grouped) -// .frame(minHeight: 700) -//} + + + #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) + } + + }