From 32aed0a6103964bbafc7ea3e97d54474db5ebb6a Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 22:41:41 +0800 Subject: [PATCH] Keyboard-interactive for Metrics --- FrameworkTests/MetricsJourneyTests.swift | 29 ++- .../MetricsKeyboardInteractiveTests.swift | 232 ++++++++++++++++++ .../Metrics/ServerMetricsModel.swift | 91 ++++++- .../MetricsInstrumentBody.swift | 1 + Memory/ARCHITECTURE.md | 14 +- TFI/App/MobileAppModel.swift | 3 +- .../Metrics/MobileMetricsModel.swift | 5 +- TFI/Features/Metrics/MobileMetricsView.swift | 14 +- .../MobileConnectionJourneyView.swift | 10 +- TFITests/MobileMetricsJourneyTests.swift | 167 +++++++++++++ TFM/Features/Metrics/MetricsJourneyView.swift | 4 +- TFM/UI/Components/ConnectionJourneyView.swift | 11 +- TFMTests/MetricsJourneyRenderingTests.swift | 126 ++++++++++ 13 files changed, 682 insertions(+), 25 deletions(-) create mode 100644 FrameworkTests/MetricsKeyboardInteractiveTests.swift create mode 100644 TFITests/MobileMetricsJourneyTests.swift create mode 100644 TFMTests/MetricsJourneyRenderingTests.swift diff --git a/FrameworkTests/MetricsJourneyTests.swift b/FrameworkTests/MetricsJourneyTests.swift index 99deddc9..cd946856 100644 --- a/FrameworkTests/MetricsJourneyTests.swift +++ b/FrameworkTests/MetricsJourneyTests.swift @@ -60,7 +60,7 @@ struct MetricsJourneyTests { hosts.upsert(host) clock.advance(2) - try await wait { server.name == host.name } + try await wait { server.name == host.name && server.collectionState == .failed } #expect(server.collectionState == .failed) model.suspend() @@ -170,6 +170,31 @@ struct MetricsJourneyTests { #expect(model.servers.isEmpty) } + @Test func suspendingHostKeyConfirmationRequiresAFreshChallengeOnResume() async throws { + let identity = Identity(name: "Deploy", username: "deploy") + let host = TermindDomain.Host(name: "Monitor", host: "example.invalid", identityID: identity.id) + let hosts = HostLibraryModel(hosts: [host], identities: [identity]) + try hosts.upsert(identity, passwordUpdate: .replace("test-only")) + let sampler = PortMetricsSampler() + let model = ServerMetricsModel( + hosts: hosts, preferences: PortMetricsPreferences(), makeSampler: { sampler }) + defer { model.shutdown() } + #expect(model.add(host)) + let server = try #require(model.servers.first) + try await wait { server.hostKeyPrompt != nil } + let observation = try #require(server.hostKey) + model.suspend() + #expect(server.hostKeyPrompt == nil && server.hostKey == nil && !server.needsAttention) + model.acceptHostKey(server, observation: observation, remember: true) + #expect(hosts.knownHosts(for: host).isEmpty) + model.resume() + try await wait { server.hostKeyPrompt != nil } + #expect(await sampler.hostKeyChallenges == 2) + model.acceptHostKey(server, observation: try #require(server.hostKey), remember: false) + try await wait { server.point != nil } + #expect(await sampler.connections == 1) + } + @Test func checkpointAndLogsSurviveAuthenticationContinuation() { let host = TermindDomain.Host(name: "Target", host: "example.invalid") let journey = ConnectionJourney() @@ -200,6 +225,7 @@ struct MetricsJourneyTests { private actor PortMetricsSampler: MetricsSampling { var connections = 0 + var hostKeyChallenges = 0 var processRequests = 0 func connect(_ configuration: SSHConnectionConfiguration) async throws { connections += 1 } func connect(_ request: SSHConnectionRequest) async throws { @@ -209,6 +235,7 @@ private actor PortMetricsSampler: MetricsSampling { algorithm: .ed25519, fingerprint: "SHA256:test" ) if !configuration.expectedHostKeys.contains(observation.identity) { + hostKeyChallenges += 1 guard let authorizeHostKey = request.authorizeHostKey else { throw TermindSSHError.untrustedHost } diff --git a/FrameworkTests/MetricsKeyboardInteractiveTests.swift b/FrameworkTests/MetricsKeyboardInteractiveTests.swift new file mode 100644 index 00000000..8bef1377 --- /dev/null +++ b/FrameworkTests/MetricsKeyboardInteractiveTests.swift @@ -0,0 +1,232 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct MetricsKeyboardInteractiveTests { + @Test func multipleRoundsContinueOneConnectionWithoutRecordingAnswers() async throws { + let sampler = JourneyMetricsSampler(rounds: [ + [], [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)], + [.init(text: "Second code", echo: false)], + ]) + let fixture = try Fixture(sampler: sampler) + defer { fixture.model.shutdown() } + let server = try fixture.add() + try await wait { server.keyboardAuthentication.current?.round == 2 } + let first = try #require(server.keyboardAuthentication.current) + #expect(server.requiresAuthentication && server.showsConnectionJourney && server.needsAttention) + #expect(!server.isConnecting) + #expect(server.journey.phase == .waiting) + #expect(server.journey.checkpoint?.kind == .authentication) + #expect(fixture.model.snapshot(for: server.id)?.state == .awaitingAuthentication) + #expect(server.status == String(localized: "Authentication required")) + #expect(await sampler.attempts == 1) + server.keyboardAuthentication.respond(first.id, answers: [" deploy ", "private-answer"]) + try await wait { server.keyboardAuthentication.current?.round == 3 } + let second = try #require(server.keyboardAuthentication.current) + #expect(second.id != first.id && second.connectionID == first.connectionID) + server.keyboardAuthentication.respond(first.id, answers: ["stale", "stale"]) + #expect(server.keyboardAuthentication.current?.id == second.id) + server.keyboardAuthentication.respond(second.id, answers: [""]) + server.keyboardAuthentication.respond(second.id, answers: ["duplicate"]) + try await wait { server.point != nil } + #expect(await sampler.attempts == 1) + #expect(await sampler.answers == [[], [" deploy ", "private-answer"], [""]]) + #expect(!server.needsAttention && !server.requiresAuthentication) + #expect(fixture.model.snapshot(for: server.id)?.state == .live) + #expect(!server.journey.plainTextLog.contains("private-answer")) + #expect(!server.journey.plainTextLog.contains(first.instruction)) + #expect(!server.journey.plainTextLog.contains(first.prompts[1].text)) + #expect(fixture.library.identities.count == 1) + } + + @Test func informationalRoundsFinishWithoutOpeningDetails() async throws { + let sampler = JourneyMetricsSampler(rounds: [[], []]) + let fixture = try Fixture(sampler: sampler) + defer { fixture.model.shutdown() } + let server = try fixture.add() + try await wait { server.point != nil } + #expect(server.keyboardAuthentication.pending.isEmpty) + #expect(!server.requiresAuthentication) + #expect(await sampler.answers == [[], []]) + #expect(!server.journey.plainTextLog.contains(String(localized: "Waiting for authentication"))) + } + + @Test func monitorsSharingAJumpHostKeepTheirChallengesIndependent() async throws { + let identity = Identity(name: "Deploy", username: "deploy") + let jump = Host(name: "Gateway", host: "gateway.invalid", identityID: identity.id) + let firstHost = Host(name: "First", host: "first.invalid", identityID: identity.id, jumpHostID: jump.id) + let secondHost = Host(name: "Second", host: "second.invalid", identityID: identity.id, jumpHostID: jump.id) + let library = HostLibraryModel(hosts: [jump, firstHost, secondHost], identities: [identity]) + try library.upsert(identity, passwordUpdate: .replace("test-only")) + let sampler = JourneyMetricsSampler(challengeHost: jump) + let model = ServerMetricsModel( + hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler }) + defer { model.shutdown() } + #expect(model.add(firstHost)) + #expect(model.add(secondHost)) + let first = try #require(model.servers.first { $0.id == firstHost.id }) + let second = try #require(model.servers.first { $0.id == secondHost.id }) + try await wait { first.keyboardAuthentication.current != nil && second.keyboardAuthentication.current != nil } + let a = try #require(first.keyboardAuthentication.current) + let b = try #require(second.keyboardAuthentication.current) + #expect(a.hostID == jump.id && b.hostID == jump.id) + #expect(a.connectionID != b.connectionID) + #expect(first.journey.checkpoint?.endpointIndex == 1) + #expect(second.journey.checkpoint?.endpointIndex == 1) + first.keyboardAuthentication.respond(b.id, answers: ["wrong-monitor"]) + #expect(first.keyboardAuthentication.current?.id == a.id) + first.keyboardAuthentication.respond(a.id, answers: ["first-answer"]) + try await wait { first.point != nil } + #expect(second.keyboardAuthentication.current?.id == b.id) + model.setEnabled(false, for: second) + #expect(second.keyboardAuthentication.pending.isEmpty) + #expect(first.selection.enabled && first.collectionState == .live) + } + + @Test(arguments: Retirement.allCases) + func retiringAttemptsCancelsPromptsAndRejectsLateCallbacks(_ retirement: Retirement) async throws { + let fixture = try Fixture() + defer { fixture.model.shutdown() } + let server = try fixture.add() + try await wait { server.keyboardAuthentication.current != nil } + let oldChallenge = try #require(server.keyboardAuthentication.current) + let oldRequest = try #require(await fixture.sampler.requests.first) + let retiredHandler = try #require(oldRequest.keyboardInteractive) + switch retirement { + case .pause: fixture.model.setEnabled(false, for: server) + case .remove: fixture.model.remove(server.id) + case .retry: fixture.model.retry(server) + case .configuration: + var host = fixture.host + host.port += 1 + fixture.library.upsert(host) + fixture.model.reconcile() + case .suspend: fixture.model.suspend() + case .shutdown: fixture.model.shutdown() + } + #expect(server.keyboardAuthentication.pending.isEmpty) + #expect(!server.needsAttention) + server.keyboardAuthentication.respond(oldChallenge.id, answers: ["stale"]) + await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) } + if retirement == .suspend { fixture.model.resume() } + if retirement == .pause { fixture.model.setEnabled(true, for: server) } + if retirement == .remove { + #expect(fixture.model.servers.isEmpty) + } else if retirement == .shutdown { + #expect(server.journey.phase == .hidden) + } else { + try await wait { server.keyboardAuthentication.current != nil } + let next = try #require(server.keyboardAuthentication.current) + #expect(next.id != oldChallenge.id) + await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) } + #expect(server.keyboardAuthentication.current?.id == next.id) + #expect(server.journey.phase == .waiting && server.needsAttention) + server.keyboardAuthentication.respond(next.id, answers: ["new-answer"]) + try await wait { server.point != nil } + #expect(await fixture.sampler.answers == [["new-answer"]]) + } + } + + @Test func transportCancellationRemovesThePendingPrompt() async throws { + let fixture = try Fixture() + defer { fixture.model.shutdown() } + let server = try fixture.add() + try await wait { server.keyboardAuthentication.current != nil } + // A transport may cancel its responder independently of the monitor's worker. + await fixture.sampler.cancelResponder() + try await wait { server.keyboardAuthentication.pending.isEmpty && !server.needsAttention } + #expect(server.journey.phase != .waiting) + } + + enum Retirement: CaseIterable { case pause, remove, retry, configuration, suspend, shutdown } + + private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws { + for _ in 0..<400 { + if condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition(), sourceLocation: sourceLocation) + } + + private struct Fixture { + let host: Host + let library: HostLibraryModel + let sampler: JourneyMetricsSampler + let model: ServerMetricsModel + + init(sampler: JourneyMetricsSampler = JourneyMetricsSampler()) throws { + self.sampler = sampler + let identity = Identity(name: "Deploy", username: "deploy") + host = Host(name: "Monitor", host: "metrics.invalid", identityID: identity.id) + library = HostLibraryModel(hosts: [host], identities: [identity]) + try library.upsert(identity, passwordUpdate: .replace("test-only")) + model = ServerMetricsModel( + hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler }) + } + + func add() throws -> ServerMetric { + #expect(model.add(host)) + return try #require(model.servers.first) + } + } +} + +@MainActor private final class JourneyMetricsPreferences: MetricsConfigurationStoring { + func load() -> [MetricsSelection] { [] } + func save(_ selections: [MetricsSelection]) {} +} + +private actor JourneyMetricsSampler: MetricsSampling { + let rounds: [[SSHKeyboardInteractiveChallenge.Prompt]] + let challengeHost: Host? + var attempts = 0 + var answers: [[String]] = [] + var requests: [SSHConnectionRequest] = [] + private var responder: Task<[String], any Error>? + + init( + rounds: [[SSHKeyboardInteractiveChallenge.Prompt]] = [[.init(text: "Verification code", echo: false)]], + challengeHost: Host? = nil + ) { + self.rounds = rounds + self.challengeHost = challengeHost + } + + func connect(_ configuration: SSHConnectionConfiguration) async throws { + Issue.record("Metrics must supply the request containing its own authentication handler") + throw CancellationError() + } + + func connect(_ request: SSHConnectionRequest) async throws { + attempts += 1 + requests.append(request) + let handler = try #require(request.keyboardInteractive) + let connectionID = UUID() + for (index, prompts) in rounds.enumerated() { + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: challengeHost?.id ?? request.configuration.hostID, + hostname: challengeHost?.host ?? request.configuration.host, port: request.configuration.port, + username: request.configuration.username, round: index + 1, name: "PAM", + instruction: "Private server instruction", prompts: prompts) + let task = Task { try await handler(challenge) } + responder = task + let answer = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + answers.append(answer) + } + } + + func cancelResponder() { responder?.cancel() } + func sample() async throws -> MetricsSample { + .init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100) + } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async {} +} diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 7f3cdf7e..4524aa14 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -22,10 +22,13 @@ public final class ServerMetric: Identifiable { public var hostKey: SSHHostKeyObservation? public var needsAttention = false public let journey = ConnectionJourney() + public let keyboardAuthentication = SSHKeyboardInteractiveQueue() public var authenticationPrompt: SSHAuthenticationPrompt? public var hostKeyPrompt: SSHHostKeyPrompt? public var hostKeyHostID: UUID? - public var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil } + public var requiresAuthentication: Bool { + hostKey != nil || authenticationPrompt != nil || keyboardAuthentication.current != nil + } public var showsConnectionJourney: Bool { requiresAuthentication || journey.isPresented } public var processes: [MetricsProcess] = [] public var processesDate: Date? @@ -71,6 +74,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { var id: UUID var continuation: AsyncStream.Continuation } + private struct PendingKeyboardAuthentication { + var id: UUID + var task: Task<[String], any Error> + } private nonisolated struct RunAttempt: Sendable { var host: Host var preparation: SSHConnectionPreparation @@ -84,8 +91,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { case stop, attention, retry } private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:] - - private nonisolated let keyboardInteractive: SSHKeyboardInteractiveHandler? + private var pendingKeyboardAuthentications: [UUID: PendingKeyboardAuthentication] = [:] public var keepsRunning: Bool { servers.contains { $0.selection.enabled } } @@ -94,10 +100,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading { makeSampler: @escaping @Sendable () -> any MetricsSampling, clock: MetricsClock = MetricsClock(), cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit }, - limitReached: @escaping () -> Void = {}, - keyboardInteractive: SSHKeyboardInteractiveHandler? = nil + limitReached: @escaping () -> Void = {} ) { - self.keyboardInteractive = keyboardInteractive self.hosts = hosts self.preferences = preferences self.makeSampler = makeSampler @@ -466,9 +470,21 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } private func cancel(_ id: UUID) { - finishHostKeyAuthorization(for: id, decision: .reject) generations[id] = nil workers.removeValue(forKey: id)?.cancel() + let hadHostKeyAuthorization = pendingHostKeyAuthorizations[id] != nil + finishHostKeyAuthorization(for: id, decision: .reject) + let authentication = pendingKeyboardAuthentications.removeValue(forKey: id) + authentication?.task.cancel() + if let server = servers.first(where: { $0.id == id }) { + if hadHostKeyAuthorization { + server.hostKey = nil + server.hostKeyHostID = nil + server.hostKeyPrompt = nil + } + if hadHostKeyAuthorization || authentication != nil { server.needsAttention = false } + server.keyboardAuthentication.cancelAll() + } if let sampler = samplers.removeValue(forKey: id) { Task { await sampler.disconnect() } } @@ -547,7 +563,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading { serverID, challenge: challenge, token: token ) }, - keyboardInteractive: keyboardInteractive + keyboardInteractive: { [weak self] challenge in + guard let self else { throw CancellationError() } + return try await self.answerKeyboardInteractiveFromWorker( + serverID, challenge: challenge, token: token) + } ) ) guard await publishConnected(serverID, token: token, hostID: attempt.host.id) else { @@ -766,6 +786,61 @@ public final class ServerMetricsModel: MetricsSnapshotReading { return .retry } + private nonisolated func answerKeyboardInteractiveFromWorker( + _ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID + ) async throws -> [String] { + guard await isCurrent(serverID, token: token) else { throw CancellationError() } + // Informational rounds must complete even when no Metrics detail is visible. + guard !challenge.prompts.isEmpty else { return [] } + guard let task = await beginKeyboardAuthentication(serverID, challenge: challenge, token: token) else { + throw CancellationError() + } + do { + let answers = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + guard await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token) else { + throw CancellationError() + } + try Task.checkCancellation() + return answers + } catch { + _ = await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token) + throw error + } + } + + private func beginKeyboardAuthentication( + _ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID + ) -> Task<[String], any Error>? { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } + let queue = server.keyboardAuthentication + // Retain the responder before yielding so retiring this attempt also cancels a not-yet-enqueued request. + let task = Task { try await queue.request(challenge) } + pendingKeyboardAuthentications[serverID] = PendingKeyboardAuthentication(id: challenge.id, task: task) + server.needsAttention = true + server.isConnecting = false + server.status = String(localized: "Authentication required") + server.collectionState = .awaitingAuthentication + server.journey.waitForAuthentication(at: challenge.hostID) + return task + } + + private func finishKeyboardAuthentication(_ serverID: UUID, requestID: UUID, token: UUID) -> Bool { + guard generations[serverID] == token, pendingKeyboardAuthentications[serverID]?.id == requestID, + let server = servers.first(where: { $0.id == serverID }) + else { return false } + pendingKeyboardAuthentications[serverID] = nil + server.needsAttention = false + server.isConnecting = true + server.status = String(localized: "Connecting…") + server.collectionState = .connecting + server.journey.resume() + return true + } + private nonisolated func authorizeHostKeyFromWorker( _ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID ) async -> SSHHostKeyDecision { diff --git a/Frameworks/TermindPresentation/MetricsInstrumentBody.swift b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift index 17a782b0..d4746566 100644 --- a/Frameworks/TermindPresentation/MetricsInstrumentBody.swift +++ b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift @@ -118,6 +118,7 @@ public struct MetricsInstrumentBody: View { } private var connectionLabel: String { + if server.keyboardAuthentication.current != nil { return String(localized: "Authentication required") } if server.requiresAuthentication { return String(localized: "Server Identity Verification Required") } return server.status } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 117d9383..b2cd6b70 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -192,8 +192,9 @@ scene. Terminal file drops and all additional transfer connections forbid Termin credential, passphrase, MFA and host-key forms, including fallback responders on jump hosts. System key authorization such as Touch ID remains allowed. Username-only and SSH `none` authentication remain eligible; actual connection success decides availability. -No upload-specific mobile sheet exists; the app-owned MFA sheet still serves Metrics -and forwarding. Cancellation interrupts opening, and temporary-file cleanup must also +No upload-specific mobile sheet exists; the app-owned MFA sheet serves forwarding, +while Metrics owns its challenges inside Connection Journey on both platforms. +Cancellation interrupts opening, and temporary-file cleanup must also remain silent. Cleanup requiring new interaction retains the existing failure report. Forwarding rules sharing a host session share authentication; a new connection must answer fresh challenges. An Identity may contain only a username: preparation @@ -767,7 +768,14 @@ processes to 20. Monitoring itself does not depend on AgentKit. Metrics order syncs independently of Vault order; local layout, process sorting, search, detail navigation, and menu-bar visibility do not. Detail observation alone enables process/filesystem collection. Authentication opens the shared Connection -Journey while the metrics coordinator retains transport ownership. Temporary trust +Journey while the metrics coordinator retains transport ownership. Each monitor owns +its keyboard-interactive queue, including challenges from its jump hosts. Metrics +always supplies its own responder, never falling back to an app-owned sheet or external +authentication window. Cards indicate required authentication without changing navigation; +opening a card presents its Journey. Leaving details retains the challenge, while explicit +authentication cancellation pauses the monitor and returns that window to the list. +Retiring an attempt cancels both queued and not-yet-enqueued responders; generation and +request IDs prevent stale answers or cleanup from affecting another attempt. Temporary trust is cleared on pause/removal/configuration change; rejection pauses the monitor. Free access admits at most three cards (paused included), atomically and without deleting restored cards after downgrade; Pro/trial access is unlimited. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 145bb178..09aebc4c 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -177,8 +177,7 @@ final class MobileAppModel { storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: settingsDefaults) library = HostLibraryModel(persistence: persistence, secrets: secrets) metrics = MobileMetricsModel( - preferences: metricsPersistence, hosts: library, services: dependencies.metrics, - keyboardInteractive: keyboardAuthentication.handler) + preferences: metricsPersistence, hosts: library, services: dependencies.metrics) let speechAudio = IOSSpeechAudioInput() let speech = SpeechTranscriptionCoordinator( service: AppleSpeechTranscriptionService(audioInput: speechAudio) diff --git a/TFI/Application/Metrics/MobileMetricsModel.swift b/TFI/Application/Metrics/MobileMetricsModel.swift index 1b87e68a..ee473111 100644 --- a/TFI/Application/Metrics/MobileMetricsModel.swift +++ b/TFI/Application/Metrics/MobileMetricsModel.swift @@ -16,15 +16,14 @@ final class MobileMetricsModel { init( preferences: any MetricsConfigurationStoring, hosts: any RemoteSessionHostStore, - services: MobileMetricsServices, - keyboardInteractive: SSHKeyboardInteractiveHandler? = nil + services: MobileMetricsServices ) { self.hosts = hosts let limit = limit core = ServerMetricsModel( hosts: hosts, preferences: preferences, makeSampler: services.makeSampler, - cardLimit: { limit.maximum }, keyboardInteractive: keyboardInteractive) + cardLimit: { limit.maximum }) } func add(_ id: UUID, pro: Bool) -> Bool { diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index bb3cc7f9..63f5c8c3 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -98,10 +98,9 @@ struct MobileMetricsDetailView: View { } }, retry: { app.metrics.core.retry(server) }, retryReconnectNow: {}, stopReconnecting: {}, - cancel: { - app.metrics.core.setEnabled(false, for: server) - dismiss() - }) + cancel: cancelAuthentication, + keyboardAuthentication: server.keyboardAuthentication, + cancelKeyboardAuthentication: cancelAuthentication) } else { GeometryReader { geometry in if MobileMetricsDetailPage.showsBothColumns(width: geometry.size.width) { @@ -128,7 +127,7 @@ struct MobileMetricsDetailView: View { .toolbar(removing: tabBarPlacement == .topBar ? .title : nil) .toolbar { ToolbarItem(placement: .principal) { - if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) { + if !server.showsConnectionJourney && !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) { Picker("Details", selection: $page) { Image(systemName: "gauge.with.dots.needle.33percent").accessibilityLabel("Status").tag( MobileMetricsDetailPage.status) @@ -156,6 +155,11 @@ struct MobileMetricsDetailView: View { .onDisappear { app.metrics.core.stopObservingDetails(observer) } } + private func cancelAuthentication() { + app.metrics.core.setEnabled(false, for: server) + dismiss() + } + private func observe() { if server.showsConnectionJourney { app.metrics.core.stopObservingDetails(observer) diff --git a/TFI/UI/Components/MobileConnectionJourneyView.swift b/TFI/UI/Components/MobileConnectionJourneyView.swift index 7e71bf5b..4977a951 100644 --- a/TFI/UI/Components/MobileConnectionJourneyView.swift +++ b/TFI/UI/Components/MobileConnectionJourneyView.swift @@ -16,6 +16,7 @@ struct MobileConnectionJourneyView: View { let stopReconnecting: () -> Void let cancel: () -> Void var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil + var cancelKeyboardAuthentication: (() -> Void)? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -51,7 +52,14 @@ struct MobileConnectionJourneyView: View { } else if let queue = keyboardAuthentication, let challenge = queue.current { SSHKeyboardInteractiveForm( challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) }, - cancel: { queue.cancel(challenge.id) }, usesContainerChrome: true + cancel: { + guard queue.current?.id == challenge.id else { return } + if let cancelKeyboardAuthentication { + cancelKeyboardAuthentication() + } else { + queue.cancel(challenge.id) + } + }, usesContainerChrome: true ) .id(challenge.id) } else if let authenticationPrompt { diff --git a/TFITests/MobileMetricsJourneyTests.swift b/TFITests/MobileMetricsJourneyTests.swift new file mode 100644 index 00000000..d532abb5 --- /dev/null +++ b/TFITests/MobileMetricsJourneyTests.swift @@ -0,0 +1,167 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.serialized) @MainActor +struct MobileMetricsJourneyTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func authenticationStaysInMetricsAcrossNavigationAndSizeChanges(_ language: String) async throws { + let sampler = MobileJourneyMetricsSampler() + let app = try MobileAppModel(dependencies: .testing(metrics: MobileMetricsServices(makeSampler: { sampler }))) + let identity = Identity(name: "Deploy", username: "deploy") + let host = TermindDomain.Host( + name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id) + try app.library.upsert(identity, passwordUpdate: .replace("test-only")) + app.library.upsert(host) + let scene = MobileSceneModel(app: app) + scene.selectedDestination = .metrics + #expect(app.metrics.add(host.id, pro: true)) + let server = try #require(app.metrics.servers.first) + let route = MetricsJourneyTestRoute() + var features = MobileFeatureRegistry() + features.register( + MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in + AnyView( + Text("Metrics") + .navigationTitle("Metrics") + .navigationDestination( + isPresented: Binding(get: { route.showsDetails }, set: { route.showsDetails = $0 }) + ) { MobileMetricsDetailView(app: app, server: server) }) + }) + let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = windowScene.keyWindow + let window = UIWindow(windowScene: windowScene) + let container = UIViewController() + let controller = UIHostingController( + rootView: MobileRootView(app: app, scene: scene, features: features) + .environment(\.locale, Locale(identifier: language)) + .environment(\.scenePhase, .active)) + container.addChild(controller) + container.view.addSubview(controller.view) + controller.didMove(toParent: container) + window.rootViewController = container + window.windowLevel = .alert + 3 + window.makeKeyAndVisible() + defer { + app.metrics.core.shutdown() + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + resize(controller, width: 430) + try await wait { server.keyboardAuthentication.current != nil } + let challenge = try #require(server.keyboardAuthentication.current) + #expect(!route.showsDetails) + #expect(app.keyboardAuthentication.pending.isEmpty) + route.showsDetails = true + for width in [430.0, 600.0, 1024.0] { + resize(controller, width: width) + try await Task.sleep(for: .milliseconds(500)) + let fields = descendants(UITextField.self, in: controller.view) + #expect(fields.count == 2) + #expect(fields.filter(\.isSecureTextEntry).count == 1) + #expect(descendants(UISegmentedControl.self, in: controller.view).isEmpty) + #expect(presented(in: controller) == nil) + #expect(server.keyboardAuthentication.current?.id == challenge.id) + #expect(app.keyboardAuthentication.pending.isEmpty) + try capture(controller.view, name: "metrics-journey-\(language)-\(Int(width))") + } + route.showsDetails = false + try await Task.sleep(for: .milliseconds(400)) + #expect(server.keyboardAuthentication.current?.id == challenge.id) + route.showsDetails = true + resize(controller, width: 430) + // Native traits exercise large text without replacing the feature navigation. + controller.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge + try await Task.sleep(for: .milliseconds(500)) + #expect(descendants(UITextField.self, in: controller.view).count == 2) + try capture(controller.view, name: "metrics-journey-\(language)-large-type") + #expect(await sampler.connections == 1) + controller.traitOverrides.preferredContentSizeCategory = .large + let fields = descendants(UITextField.self, in: controller.view) + let account = try #require(fields.first { !$0.isSecureTextEntry }) + let code = try #require(fields.first { $0.isSecureTextEntry }) + account.text = "deploy" + account.sendActions(for: .editingChanged) + code.text = "test-code" + code.sendActions(for: .editingChanged) + #expect(code.becomeFirstResponder()) + code.sendActions(for: .editingDidEndOnExit) + try await wait { server.point != nil && !server.showsConnectionJourney } + try await Task.sleep(for: .milliseconds(400)) + #expect(descendants(UITextField.self, in: controller.view).isEmpty) + #expect(app.keyboardAuthentication.pending.isEmpty) + #expect(presented(in: controller) == nil) + #expect(await sampler.answers == ["deploy", "test-code"]) + #expect(await sampler.connections == 1) + try capture(controller.view, name: "metrics-journey-\(language)-live") + } + + private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws { + for _ in 0..<400 { + if condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition(), sourceLocation: sourceLocation) + } + + private func resize(_ controller: UIViewController, width: CGFloat) { + let frame = CGRect(x: 0, y: 0, width: width, height: 900) + controller.view.window?.frame = frame + controller.parent?.view.frame = frame + controller.traitOverrides.horizontalSizeClass = width >= 600 ? .regular : .compact + controller.view.frame = frame + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + } + + private func descendants(_ type: T.Type, in view: UIView) -> [T] { + (view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(type, in: $0) } + } + + private func presented(in controller: UIViewController) -> UIViewController? { + controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first + } + + private func capture(_ view: UIView, name: String) throws { + let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } + let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone" + Attachment.record(data, named: "\(device)-\(name).png") + let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appending(path: "\(device)-\(name).png")) + print("Metrics Journey snapshot: \(directory.path)/\(device)-\(name).png") + } +} + +@Observable @MainActor private final class MetricsJourneyTestRoute { + var showsDetails = false +} + +private actor MobileJourneyMetricsSampler: MetricsSampling { + var connections = 0 + var answers: [String] = [] + func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() } + func connect(_ request: SSHConnectionRequest) async throws { + connections += 1 + let handler = try #require(request.keyboardInteractive) + answers = try await handler( + SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: request.configuration.hostID, + hostname: request.configuration.host, port: request.configuration.port, + username: request.configuration.username, round: 1, name: "PAM", + instruction: "Enter your account and verification code.", + prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)])) + } + func sample() async throws -> MetricsSample { + .init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100) + } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async {} +} diff --git a/TFM/Features/Metrics/MetricsJourneyView.swift b/TFM/Features/Metrics/MetricsJourneyView.swift index 90c12685..5b15c681 100644 --- a/TFM/Features/Metrics/MetricsJourneyView.swift +++ b/TFM/Features/Metrics/MetricsJourneyView.swift @@ -24,7 +24,9 @@ struct MetricsJourneyView: View { trustHostKey: { model.acceptHostKey(server, observation: $0, remember: true) }, denyHostKey: cancel, retry: { model.retry(server) }, - cancel: cancel + cancel: cancel, + keyboardAuthentication: server.keyboardAuthentication, + cancelKeyboardAuthentication: cancel ) .environment(app) } diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index 3726d1f8..8415b7cc 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -158,6 +158,7 @@ struct ConnectionJourneyView: View { var failureRetryLabel: LocalizedStringKey = "Retry" var compactFailureActions = false var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil + var cancelKeyboardAuthentication: (() -> Void)? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -195,7 +196,15 @@ struct ConnectionJourneyView: View { SSHKeyboardInteractiveForm( challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) }, - cancel: { queue.cancel(challenge.id) }, keyboard: DesktopConnectionFormKeyboard() + cancel: { + guard queue.current?.id == challenge.id else { return } + if let cancelKeyboardAuthentication { + cancelKeyboardAuthentication() + } else { + queue.cancel(challenge.id) + } + }, + keyboard: DesktopConnectionFormKeyboard() ) .id(challenge.id) .connectionFormChrome(isEnabled: true) diff --git a/TFMTests/MetricsJourneyRenderingTests.swift b/TFMTests/MetricsJourneyRenderingTests.swift new file mode 100644 index 00000000..02814a49 --- /dev/null +++ b/TFMTests/MetricsJourneyRenderingTests.swift @@ -0,0 +1,126 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@testable import Termind + +@Suite(.serialized) @MainActor +struct MetricsJourneyRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func metricsAuthenticateInsideTheirJourneyWithoutOpeningAnExternalWindow(_ language: String) async throws { + let identity = Identity(name: "Deploy", username: "deploy") + let host = Host(name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id) + let library = HostLibraryModel(hosts: [host], identities: [identity]) + try library.upsert(identity, passwordUpdate: .replace("test-only")) + let sampler = DesktopJourneyMetricsSampler() + let model = ServerMetricsModel( + hosts: library, preferences: DesktopJourneyPreferences(), makeSampler: { sampler }) + let app = AppModel(hosts: library, serverMetrics: model) + await sampler.setFallback(app.externalAuthentication.keyboardInteractive) + let navigation = WindowModel() + navigation.selectedDestination = .metrics + navigation.metricsModel = model + let controller = NSHostingController( + rootView: ServerMetricsView().environment(app).environment(navigation) + .environment(\.locale, Locale(identifier: language))) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 740), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.makeKeyAndOrderFront(nil) + defer { + model.shutdown() + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + #expect(model.add(host)) + let server = try #require(model.servers.first) + for _ in 0..<400 where server.keyboardAuthentication.current == nil { + try await Task.sleep(for: .milliseconds(5)) + } + let challenge = try #require(server.keyboardAuthentication.current) + #expect(navigation.metricsHostID == nil) + #expect(app.externalAuthentication.pending.isEmpty) + #expect(app.externalAuthentication.presentationRevision == 0) + navigation.metricsHostID = host.id + for width in [600.0, 1000.0] { + window.setContentSize(NSSize(width: width, height: 740)) + try await Task.sleep(for: .milliseconds(300)) + controller.view.layoutSubtreeIfNeeded() + let fields = editableFields(in: controller.view) + #expect(fields.count == 2) + #expect(fields.filter { $0 is NSSecureTextField }.count == 1) + #expect(window.attachedSheet == nil) + #expect(!navigation.showsCompactMetricsDetail) + try capture(controller.view, name: "macos-metrics-journey-\(language)-\(Int(width))") + } + navigation.metricsHostID = nil + try await Task.sleep(for: .milliseconds(300)) + #expect(server.keyboardAuthentication.current?.id == challenge.id) + navigation.metricsHostID = host.id + try await Task.sleep(for: .milliseconds(300)) + window.makeKeyAndOrderFront(nil) + let escape = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) + NSApp.sendEvent(escape) + for _ in 0..<100 where server.selection.enabled { try await Task.sleep(for: .milliseconds(5)) } + #expect(!server.selection.enabled) + #expect(navigation.metricsHostID == nil) + #expect(server.keyboardAuthentication.pending.isEmpty) + #expect(app.externalAuthentication.pending.isEmpty) + #expect(await sampler.connections == 1) + } + + private func editableFields(in view: NSView) -> [NSTextField] { + if let field = view as? NSTextField, field.isEditable { return [field] } + return view.subviews.flatMap { editableFields(in: $0) } + } + + private func capture(_ view: NSView, name: String) throws { + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + Attachment.record(data, named: "\(name).png") + let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appending(path: "\(name).png")) + } +} + +@MainActor private final class DesktopJourneyPreferences: MetricsConfigurationStoring { + func load() -> [MetricsSelection] { [] } + func save(_ selections: [MetricsSelection]) {} +} + +private actor DesktopJourneyMetricsSampler: MetricsSampling { + var connections = 0 + private var fallback: SSHKeyboardInteractiveHandler? + func setFallback(_ handler: @escaping SSHKeyboardInteractiveHandler) { fallback = handler } + func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() } + func connect(_ request: SSHConnectionRequest) async throws { + connections += 1 + // Match the live exec session's fallback path: Metrics must always override it. + let handler = try #require(request.withKeyboardInteractiveFallback(fallback).keyboardInteractive) + _ = try await handler( + SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: request.configuration.hostID, + hostname: request.configuration.host, port: request.configuration.port, + username: request.configuration.username, round: 1, name: "PAM", + instruction: "Enter your account and verification code.", + prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)])) + } + func sample() async throws -> MetricsSample { + .init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100) + } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async {} +}