325 lines
13 KiB
Swift
325 lines
13 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import AppKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindPresentation
|
|
|
|
struct ExternalAuthWindowPresenter: View {
|
|
static let sceneID = "external-ssh-authentication"
|
|
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(\.openWindow) private var openWindow
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.frame(width: 0, height: 0)
|
|
.accessibilityHidden(true)
|
|
.onChange(of: app.externalAuthentication.presentationRevision, initial: true) {
|
|
guard app.externalAuthentication.current != nil else { return }
|
|
openWindow(id: Self.sceneID)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ExternalAuthWindow: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(\.dismissWindow) private var dismissWindow
|
|
@State private var idleDismissal: Task<Void, Never>?
|
|
@State private var displayedRequestID: UUID?
|
|
@State private var displayedPending: ExternalAuthCoordinator.Pending?
|
|
@State private var isClosingAfterIdle = false
|
|
|
|
private var pending: ExternalAuthCoordinator.Pending? {
|
|
app.externalAuthentication.current
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let visiblePending {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
Text(AppStrings.MCP.requestedBy(visiblePending.clientName))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
requestView(visiblePending)
|
|
}
|
|
// The window glass is already the visual container. Keep one
|
|
// generous inset instead of nesting material cards.
|
|
.padding(Metrics.Space.xxl)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
// Authentication requests vary substantially in height. A passphrase
|
|
// request has only one field, while identity and secret requests can
|
|
// contain several rows. Let the content establish the window height so
|
|
// compact requests do not inherit the empty space needed by larger ones.
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(minWidth: 520)
|
|
// A background does not contribute an ideal size. The former ZStack's
|
|
// flexible Color accepted the current window height and fed it back to
|
|
// `.windowResizability(.contentSize)`, preventing shorter Passphrase
|
|
// requests from shrinking the window after a taller Identity form.
|
|
.background {
|
|
Color.clear
|
|
.termindGlassSurface(cornerRadius: Metrics.Radius.large)
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(false)
|
|
.accessibilityHidden(true)
|
|
}
|
|
// SwiftUI otherwise supplies its own opaque window background below
|
|
// the glass view. Clearing both container and NSWindow backgrounds is
|
|
// what leaves a single, genuine material surface.
|
|
.containerBackground(.clear, for: .window)
|
|
.background {
|
|
ExternalAuthWindowChromeBridge(presentationID: pending?.id)
|
|
}
|
|
.onAppear {
|
|
isClosingAfterIdle = false
|
|
displayedRequestID = pending?.id
|
|
displayedPending = pending
|
|
if let id = pending?.id { app.externalAuthentication.markPresented(id) }
|
|
}
|
|
.onChange(of: pending?.id, initial: true) {
|
|
if !isClosingAfterIdle, let pending {
|
|
displayedRequestID = pending.id
|
|
displayedPending = pending
|
|
app.externalAuthentication.markPresented(pending.id)
|
|
}
|
|
updateIdleDismissal()
|
|
}
|
|
.onDisappear {
|
|
idleDismissal?.cancel()
|
|
if !isClosingAfterIdle, app.externalAuthentication.current != nil {
|
|
app.externalAuthentication.windowClosed(presenting: displayedRequestID)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keep the current form visible across the brief Identity-to-Passphrase
|
|
/// hand-off. The next request replaces it directly; completion closes the
|
|
/// window without an intermediate animation.
|
|
private var visiblePending: ExternalAuthCoordinator.Pending? {
|
|
pending ?? displayedPending
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func requestView(_ pending: ExternalAuthCoordinator.Pending) -> some View {
|
|
switch pending.request {
|
|
case .ssh(let request):
|
|
if let signing = request.securityKey {
|
|
AgentSecurityKeySigningCard(request: signing) {
|
|
app.externalAuthentication.respondSSH($0, requestID: request.id)
|
|
}
|
|
} else {
|
|
DesktopSSHAuthenticationForm(
|
|
prompt: request.journeyPrompt,
|
|
submit: { identityID, username, password, passphrase, keyID, save in
|
|
app.externalAuthentication.respondSSH(
|
|
.submit(
|
|
AgentSSHAuthenticationAnswer(
|
|
identityID: identityID,
|
|
identityName: identityID.flatMap {
|
|
app.hostLibrary.identity($0)?.name
|
|
},
|
|
username: username, password: password,
|
|
passphrase: passphrase, keyID: keyID,
|
|
save: request.requiresPassphrase ? false : save
|
|
)
|
|
), requestID: request.id
|
|
)
|
|
},
|
|
cancel: { app.externalAuthentication.respondSSH(.cancelled, requestID: request.id) },
|
|
usesContainerChrome: false,
|
|
respondKeyboardInteractive: {
|
|
app.externalAuthentication.respondSSH(.keyboardInteractive($0), requestID: request.id)
|
|
}
|
|
)
|
|
.id(pending.id)
|
|
}
|
|
case .secret(let request):
|
|
ExternalSecretRequestForm(
|
|
request: request,
|
|
submit: { response in
|
|
app.externalAuthentication.respondSecret(response)
|
|
}
|
|
)
|
|
.id(pending.id)
|
|
case .hostKey(let request):
|
|
DesktopSSHHostKeyConfirmationForm(
|
|
prompt: request.prompt,
|
|
displayAddress: request.address,
|
|
proceed: {
|
|
app.externalAuthentication.respondHostKey(.continueOnce)
|
|
},
|
|
trust: request.allowsPersistentTrust
|
|
? {
|
|
app.externalAuthentication.respondHostKey(.trustAndContinue)
|
|
} : nil,
|
|
deny: {
|
|
app.externalAuthentication.respondHostKey(.deny)
|
|
},
|
|
usesContainerChrome: false
|
|
)
|
|
.id(pending.id)
|
|
}
|
|
}
|
|
|
|
private func updateIdleDismissal() {
|
|
idleDismissal?.cancel()
|
|
idleDismissal = nil
|
|
guard pending == nil else { return }
|
|
|
|
// An SSH flow can answer Identity, briefly empty the coordinator, and
|
|
// immediately enqueue Passphrase for the selected protected key. Do not
|
|
// interpret that hand-off as completion: give the resumed resolver one
|
|
// main-actor turn to publish its next checkpoint before closing. The
|
|
// small bounded grace also avoids a visible close/reopen flash.
|
|
idleDismissal = Task { @MainActor in
|
|
do {
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
} catch {
|
|
return
|
|
}
|
|
guard app.externalAuthentication.current == nil else { return }
|
|
isClosingAfterIdle = true
|
|
dismissWindow(id: ExternalAuthWindowPresenter.sceneID)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Native chrome and presentation policy for the external authentication scene.
|
|
@MainActor
|
|
enum ExternalAuthWindowChrome {
|
|
static func configure(_ window: NSWindow, presentsRequest: Bool = false) {
|
|
window.styleMask.insert([
|
|
.titled, .closable, .miniaturizable, .fullSizeContentView,
|
|
])
|
|
window.title = String(localized: AppStrings.Connections.authenticationRequest)
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = true
|
|
window.titlebarSeparatorStyle = .none
|
|
window.isOpaque = false
|
|
window.backgroundColor = .clear
|
|
window.isMovableByWindowBackground = false
|
|
window.hasShadow = true
|
|
window.level = .floating
|
|
window.hidesOnDeactivate = false
|
|
window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary])
|
|
|
|
for button in [
|
|
NSWindow.ButtonType.closeButton,
|
|
NSWindow.ButtonType.miniaturizeButton,
|
|
NSWindow.ButtonType.zoomButton,
|
|
] {
|
|
window.standardWindowButton(button)?.isHidden = false
|
|
}
|
|
|
|
if presentsRequest {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.orderFrontRegardless()
|
|
window.makeKeyAndOrderFront(nil)
|
|
// FocusState may resolve during the same layout pass that attached
|
|
// this bridge. Reassert key status once the SecureField exists.
|
|
Task { @MainActor [weak window] in window?.makeKey() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ExternalAuthWindowChromeBridge: NSViewRepresentable {
|
|
let presentationID: UUID?
|
|
|
|
func makeNSView(context: Context) -> ExternalAuthWindowChromeBridgeView {
|
|
ExternalAuthWindowChromeBridgeView()
|
|
}
|
|
|
|
func updateNSView(_ view: ExternalAuthWindowChromeBridgeView, context: Context) {
|
|
view.configureWindow(presentationID: presentationID)
|
|
}
|
|
}
|
|
|
|
private final class ExternalAuthWindowChromeBridgeView: NSView {
|
|
private var presentedRequestID: UUID?
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
configureWindow(presentationID: nil)
|
|
}
|
|
|
|
func configureWindow(presentationID: UUID?) {
|
|
guard let window else { return }
|
|
let presentsRequest = presentationID != nil && presentationID != presentedRequestID
|
|
presentedRequestID = presentationID
|
|
ExternalAuthWindowChrome.configure(window, presentsRequest: presentsRequest)
|
|
}
|
|
}
|
|
|
|
/// Secret-only checkpoint used by the external `request_user_secret` tool.
|
|
private struct ExternalSecretRequestForm: View {
|
|
let request: AgentUserInputRequest
|
|
let submit: (AgentUserInputResponse) -> Void
|
|
|
|
@State private var secret = ""
|
|
@FocusState private var isFocused: Bool
|
|
|
|
private var question: AgentUserQuestion? { request.questions.first }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
Label(AppStrings.MCP.secretInput.swiftUIKey, systemImage: "key.fill")
|
|
.font(.headline)
|
|
if let question {
|
|
Text(question.prompt)
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
if !request.purpose.isEmpty {
|
|
LabeledContent(AppStrings.MCP.purpose.swiftUIKey, value: request.purpose)
|
|
}
|
|
RevealableSecretField(AppStrings.Keychain.secret, text: $secret, appearance: .roundedBorder)
|
|
.focused($isFocused)
|
|
Text(
|
|
AppStrings.MCP.secretHandleExplanation
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack {
|
|
Button(AppStrings.Common.cancel.swiftUIKey) { cancel() }
|
|
.keyboardShortcut(.cancelAction)
|
|
Spacer()
|
|
Button(AppStrings.Common.continue.swiftUIKey) { submitSecret() }
|
|
.transcriptProminentButtonStyle()
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(secret.isEmpty || question == nil)
|
|
}
|
|
}
|
|
.activeFormKeyboardActions(
|
|
isDefaultEnabled: !secret.isEmpty && question != nil,
|
|
onDefault: submitSecret,
|
|
onCancel: cancel
|
|
)
|
|
.onAppear { isFocused = true }
|
|
.onDisappear { secret = "" }
|
|
}
|
|
|
|
private func submitSecret() {
|
|
guard let question, !secret.isEmpty else { return }
|
|
var bytes = Data(secret.utf8)
|
|
secret = ""
|
|
submit(
|
|
.answered([
|
|
AgentUserInputAnswer(
|
|
questionID: question.id,
|
|
value: .secret(AgentSecret(consumingUTF8: &bytes))
|
|
)
|
|
]))
|
|
}
|
|
|
|
private func cancel() {
|
|
secret = ""
|
|
submit(.cancelled)
|
|
}
|
|
}
|