Files
Termind/TFM/Features/Terminal/SSHSessionModel.swift
T

1313 lines
58 KiB
Swift

import Combine
import Foundation
import GhosttyTerminal
import GhosttyTheme
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
/// Workspace-owned routing for terminal input and shell completion.
/// Class-bound so a sshSession can keep the relationship weak while panes move
/// between Workspaces.
protocol SSHSessionInputRouting: AnyObject {
func routeInteractiveInput(_ data: Data, from origin: SSHSessionModel)
func sshSessionDidInvalidateCommandGeneration(_ sshSession: SSHSessionModel)
func sshSession(
_ sshSession: SSHSessionModel,
didChangeWorkingDirectory workingDirectory: String?
)
func sshSessionDidChangeRemoteSystemInfo(_ sshSession: SSHSessionModel)
func sshSessionDidChangeRemoteWindowTitle(_ sshSession: SSHSessionModel)
func closeSSHSessionPane(afterExit sshSession: SSHSessionModel)
}
/// One terminal sshSession inside a pane.
///
/// The screen buffer lives inside libghostty. This model bridges its in-memory
/// terminal backend to one SSH session and exposes connection state to SwiftUI.
@Observable
final class SSHSessionModel: Identifiable {
let id = UUID()
/// Never optional. Termind ships no local terminal — the App Sandbox has no
/// route to a login shell worth having, and shipping a crippled one would be
/// worse than shipping none — so a sshSession is always a sshSession *on* a host.
var hostID: Host.ID
var title: String
var status: ConnectionStatus = .disconnected {
didSet {
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
if status.isLive != oldValue.isLive {
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
}
}
}
var workingDirectory: String? {
didSet {
guard workingDirectory != oldValue else { return }
invalidateCommandGeneration()
inputRouter?.sshSession(
self, didChangeWorkingDirectory: workingDirectory
)
}
}
private(set) var homeResolution: RemoteHomeResolutionState = .pending
var displayedWorkingDirectory: String? {
guard let workingDirectory, !homeResolution.isPending else { return nil }
return displayPath(workingDirectory)
}
func displayPath(_ path: String) -> String {
RemotePath.displayed(path, home: homeResolution)
}
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
var hasUnreadOutput: Bool = false
/// History for an unsaved Quick Connect destination. Saved Hosts use the
/// app-wide CommandHistoryModel instead so every window sees the same list.
private(set) var transientCommandHistory: [CommandHistoryEntry] = []
/// Find stays with this terminal's scrollback rather than with the transient
/// SwiftUI pane mounting it, so moving a terminal between panes carries its
/// open search and retained query along with the buffer they search. Closing
/// the bar ends Ghostty's highlights but deliberately keeps the query,
/// matching VS Code when ⌘F is used again in the same terminal.
///
/// Two properties rather than one struct, because Observation invalidates
/// per stored property. A single `find` value meant every letter typed into
/// the titlebar Find field invalidated everything that only wanted to know
/// whether the search was *open* — which is `WorkspaceContentView`, and
/// which rebuilt every pane in the Workspace and remounted every Ghostty
/// surface, once per keystroke.
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in self?.performFindBinding($0) },
focus: { [weak self] in self?.requestTerminalFocus() }
)
/// Resolved once at the beginning of each connection attempt.
private(set) var isShellIntegrationEnabled = true
/// Whether the remote shell accepted Termind's authenticated OSC integration.
var shellIntegrationAvailable: Bool = false
private(set) var integratedShell: String?
/// The most recent OSC 0/2 title reported by this SSH connection.
///
/// Ghostty owns parsing and publishes the value through `TerminalViewState`.
/// Keep a session-scoped copy here so reconnecting can discard a title from
/// the old remote shell even though the long-lived terminal surface survives.
private(set) var remoteWindowTitle: String? {
didSet {
guard remoteWindowTitle != oldValue else { return }
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
}
}
var menuBarWindowTitle: String? {
status.isLive ? remoteWindowTitle : nil
}
private(set) var remoteSystemInfo: RemoteSystemInfo? {
didSet {
guard remoteSystemInfo != oldValue else { return }
inputRouter?.sshSessionDidChangeRemoteSystemInfo(self)
}
}
private(set) var isShellPromptReady = false
private struct ActiveShellCommand {
var historyID: CommandHistoryEntry.ID?
}
private var activeShellCommand: ActiveShellCommand?
private(set) var commandGeneratorRevision: UInt64 = 0
private let commandDraft = TerminalCommandDraftController()
/// Non-nil while a connection is waiting for the user to approve a first or
/// additional server fingerprint. No credential is sent before this clears.
var pendingHostKey: SSHHostKeyPrompt? { connection.pendingHostKey }
/// Set when this connection needs a login or credentials. The terminal view
/// presents the editor; the sshSession remains disconnected until it receives
/// either Continue or Continue and Save.
var pendingAuthentication: SSHAuthenticationPrompt?
let connection = RemoteSessionConnectionController()
var connectionJourney: ConnectionJourney { connection.journey }
private let terminalSurface: PersistentTerminalViewOwner
var terminalState: TerminalViewState { terminalSurface.state }
/// Owns the native terminal across SplitNode reparenting. Keeping this
/// beside the sshSession makes the surface lifetime match the SSH sshSession,
/// rather than the transient SwiftUI branch that currently displays it.
var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface }
private weak var statusStore: (any RemoteSessionHostStore)?
weak var commandHistoryStore: CommandHistoryModel?
private var ssh: SSHPTYChannel?
var connectionCoordinator: RemoteConnectionCoordinator?
private var connectionTask: Task<Void, Never>?
private var startsOnMount = true
private(set) var connectionAttemptID: UUID?
private var reconnectController: SessionReconnectController { connection.reconnect }
private var workingDirectoryObservation: AnyCancellable?
private var remoteWindowTitleObservation: AnyCancellable?
/// The live terminal's frozen route and credential IDs. Password, Key, and
/// passphrase material are resolved only for each attempted transfer
/// connection and never retained here. Cleared with the SSH connection.
private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot?
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var connectionRoute: SSHConnectionRoute?
var connectionOrigin: RemoteConnectionOrigin = .library
var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence }
private var transientHost: Host?
private var usernameHint: String?
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:]
private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] {
get { connection.temporaryHostKeys }
set { connection.temporaryHostKeys = newValue }
}
/// Installed by the Workspace currently holding this sshSession. Weak because
/// the Workspace owns the pane, which owns this model.
weak var inputRouter: (any SSHSessionInputRouting)?
/// Injection point for routing tests. Production sessions leave this nil
/// and write to their SSH actor as before.
var inputSink: ((Data) -> Void)?
/// A caller-supplied command submitted once after the first successful
/// connection. Authentication and host-key prompts keep it alive; failure,
/// cancellation, or an explicit disconnect consumes it without running.
/// Commands generated solely to synchronize an SFTP path opt out of History.
private struct PendingInitialCommand {
var command: String
var recordsInHistory: Bool
}
private var pendingInitialCommand: PendingInitialCommand?
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
private var pendingConnectionStartupCommand: PendingInitialCommand?
private var commandExcludedFromHistory: String?
private var isInitialCommandSubmissionReady = false
var reconnectState: ReconnectState? { connection.reconnectState }
func openPTYSFTPChannel(for attemptID: UUID?) async throws -> any SFTPChannelProtocol {
guard status == .connected, connectionAttemptID == attemptID, let ssh else {
throw SFTPError.notConnected
}
let client = try await ssh.openSFTPChannel()
guard status == .connected, connectionAttemptID == attemptID, self.ssh === ssh else {
await client.disconnect()
throw CancellationError()
}
return client
}
convenience init(hostID: Host.ID, title: String, initialCommand: String? = nil) {
self.init(
hostID: hostID, title: title, initialCommand: initialCommand,
recordsInitialCommandInHistory: true
)
}
convenience init(
hostID: Host.ID, title: String, initialCommand: String?,
recordsInitialCommandInHistory: Bool
) {
self.init(
hostID: hostID, title: title, transientHost: nil, usernameHint: nil,
initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory
)
}
convenience init(host: Host, usernameHint: String? = nil, initialCommand: String? = nil) {
self.init(
host: host, usernameHint: usernameHint, initialCommand: initialCommand,
recordsInitialCommandInHistory: true
)
}
convenience init(
host: Host, usernameHint: String? = nil, initialCommand: String?,
recordsInitialCommandInHistory: Bool
) {
self.init(
hostID: host.id, title: host.name, transientHost: host,
usernameHint: usernameHint, initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory
)
}
private init(
hostID: Host.ID, title: String, transientHost: Host?, usernameHint: String?,
initialCommand: String?, recordsInitialCommandInHistory: Bool
) {
self.hostID = hostID
self.title = title
self.transientHost = transientHost
self.usernameHint = usernameHint
self.pendingInitialCommand = initialCommand.map {
PendingInitialCommand(
command: $0, recordsInHistory: recordsInitialCommandInHistory
)
}
let relay = SSHSessionRelay()
let surface = PersistentTerminalViewOwner(
write: { data, isInteractiveInput in
Task { @MainActor in
relay.owner?.handleTerminalWrite(
data, isInteractiveInput: isInteractiveInput
)
}
},
resize: { viewport in
Task { @MainActor in relay.owner?.resizeSSH(viewport) }
}
)
terminalSurface = surface
relay.owner = self
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
observeWorkingDirectory(from: surface.state, through: relay)
observeRemoteWindowTitle(from: surface.state, through: relay)
}
var agentTemporaryTarget: AgentHostTarget? {
guard statusStore?.host(hostID) == nil, let host = transientHost else { return nil }
let state: String
switch status {
case .disconnected: state = "disconnected"
case .connecting: state = "connecting"
case .connected: state = "connected"
case .reconnecting: state = "reconnecting"
case .failed: state = "failed"
}
return AgentHostTarget(
id: host.id, displayName: host.name, address: host.displayAddress,
tags: host.tags, connectionState: state, isTemporary: true,
groupID: nil, groupPath: nil
)
}
var agentConnectionConfiguration: SSHConnectionConfiguration? {
guard let transferSnapshot, let statusStore else { return nil }
return try? statusStore.additionalConnectionRequest(from: transferSnapshot).configuration
}
var transferHostStore: (any RemoteSessionHostStore)? { statusStore }
func additionalConnectionRequest(
from snapshot: SSHAdditionalConnectionSnapshot
) throws -> SSHConnectionRequest {
guard let statusStore else { throw TermindSSHError.missingCredential }
return try statusStore.additionalConnectionRequest(from: snapshot)
}
func requestTerminalFocus() {
terminalSurface.requestFocus()
}
private func performFindBinding(_ action: String) {
terminalSurface.performBindingAction(action)
}
/// Text arriving at this sshSession's prompt — the one way anything reaches a
/// sshSession.
///
/// Every programmatic surface that types into a shell comes through here: a
/// snippet or an AI command card. One choke point
/// makes broadcast possible — see `Workspace.deliver`; `submit` appends the
/// newline that the user's own Return key would otherwise supply.
func insert(_ text: String, submit: Bool = false) {
invalidateCommandGeneration()
writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8))
}
var canGenerateFromCurrentDraft: Bool {
status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady
&& TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "")
}
func requestCurrentDraft() async throws -> String {
try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in
self?.writeInputDirectly(data)
}
}
/// Clears the shell's edit buffer through the installed line-editor widget,
/// then inserts one literal command as a bracketed paste. There is
/// intentionally no carriage return in this payload.
func replaceCurrentDraft(with command: String) {
writeInputDirectly(commandDraft.replacementPayload(for: command))
}
private func invalidateCommandGeneration() {
commandGeneratorRevision &+= 1
inputRouter?.sshSessionDidInvalidateCommandGeneration(self)
}
private func resetCommandGeneratorShellState() {
integratedShell = nil
remoteSystemInfo = nil
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
pendingConnectionStartupCommand = nil
activeShellCommand = nil
commandDraft.cancel()
invalidateCommandGeneration()
}
func connectIfNeeded(using store: any RemoteSessionHostStore) {
guard startsOnMount, status == .connecting else { return }
connect(using: store)
}
func connect(using store: any RemoteSessionHostStore) {
startsOnMount = false
startConnection(using: store)
}
private func startConnection(using store: any RemoteSessionHostStore) {
commandMonitor.endConnection()
resetCommandGeneratorShellState()
remoteWindowTitle = nil
homeResolution = .unavailable
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
workingDirectory = nil
transferSnapshot = nil
commandExcludedFromHistory = nil
statusStore = store
let attemptID = UUID()
connectionAttemptID = attemptID
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
guard let host = connectionHost(hostID, using: store) else {
connection.stopRecovery()
connectionAttemptID = nil
status = .failed(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable))
connectionJourney.fail(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable))
return
}
isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled(
globalSetting: DesktopSettingsKey.isShellIntegrationEnabled)
if pendingInitialCommand == nil,
let snippetID = host.startupSnippetID,
let body = store.snippet(snippetID)?.body,
!body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
pendingConnectionStartupCommand = PendingInitialCommand(
command: body, recordsInHistory: true
)
}
homeResolution = isShellIntegrationEnabled ? .pending : .unavailable
let route = connectionRoute?.hosts ?? store.route(for: host)
let identity = store.identity(for: host)
let stagedAuthentication = suppliedAuthentications[host.id]
let username =
stagedAuthentication?.username
?? identity?.username
?? usernameHint
connectionJourney.begin(
host: host, route: route, purpose: .sshSession, username: username,
mode: reconnecting ? .reconnection : .connection
)
if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) {
requestProxyAuthentication(for: proxyHost, using: store)
return
}
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route),
let submission = suppliedAuthentications[passwordHost.id]
{
requestAuthentication(
for: passwordHost, username: submission.username,
selectedKeyID: submission.keyID, requiresPassphrase: false
)
return
}
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
), let submission = suppliedAuthentications[passphraseHost.id] {
requestAuthentication(
for: passphraseHost, username: submission.username,
selectedKeyID: submission.keyID, requiresPassphrase: true,
preserveSubmission: true
)
return
}
guard let username, !username.trimmingCharacters(in: .whitespaces).isEmpty else {
requestAuthentication(
for: host, username: "", selectedKeyID: identity?.keyID,
requiresPassphrase: false
)
return
}
let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets()
let suppliedAuthentication = attemptAuthentications[host.id]
var credentials: [SSHCredentials] = []
var credentialFailures: [String] = []
var hasConfiguredCredential = false
let selectedKeyID: SSHKeyRef.ID?
if let suppliedAuthentication {
selectedKeyID = suppliedAuthentication.keyID
} else {
selectedKeyID = identity?.keyID
}
if let suppliedAuthentication {
hasConfiguredCredential =
suppliedAuthentication.keyID != nil
|| suppliedAuthentication.hasPassword
|| suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true
if let keyID = suppliedAuthentication.keyID {
if let key = store.key(keyID) {
do {
if let credential = try store.connectionKeySecretCredential(for: key) {
credentials.append(
credential.sshCredential(
for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection()
)
)
} else {
credentialFailures.append(
String(localized: AppStrings.Terminal.theSelectedKeyHasNoSavedPrivateKey)
)
}
} catch {
credentialFailures.append(error.localizedDescription)
}
} else {
credentialFailures.append(
String(localized: AppStrings.Terminal.theSelectedKeyIsNoLongerAvailable)
)
}
}
if let password = suppliedAuthentication.passwordForConnection() {
credentials.append(.password(password))
} else if let identityID = suppliedAuthentication.identityID,
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
{
do {
if let password = try store.connectionPasswordSecret(for: selectedIdentity) {
credentials.append(.password(password))
} else {
credentialFailures.append(
String(localized: AppStrings.Terminal.theSavedPasswordIsNoLongerAvailable)
)
}
} catch {
credentialFailures.append(error.localizedDescription)
}
}
} else if let identity {
let resolution = store.sshCredentials(for: identity)
if let keyID = resolution.requiredPassphraseKeyID {
requestAuthentication(
for: host, username: username, selectedKeyID: keyID,
requiresPassphrase: true
)
return
}
credentials = resolution.credentials
credentialFailures = resolution.failures
hasConfiguredCredential = resolution.hasConfiguredCredential
}
if credentials.isEmpty, suppliedAuthentication == nil, hasConfiguredCredential {
requestAuthentication(
for: host, username: username,
selectedKeyID: selectedKeyID,
failureMessage: hasConfiguredCredential
? credentialFailures.joined(separator: "\n")
: nil
)
return
}
let proxy: SSHTransportProxy?
let jumps: [SSHRouteHopConfiguration]
let expectedHostKeys: Set<SSHHostKeyIdentity>
do {
proxy = try store.transportProxy(
for: host, submission: attemptProxyAuthentications[host.id])
if let connectionRoute {
jumps = try store.sshJumpConfigurations(
for: connectionRoute, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
} else {
jumps = try store.sshJumpConfigurations(
for: host, submissions: attemptAuthentications,
proxySubmissions: attemptProxyAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
}
expectedHostKeys = try store.expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
)
} catch {
if let sshError = error as? TermindSSHError,
sshError.shouldRequestAuthentication,
let id = sshError.routeHostID,
let jump = connectionHost(id, using: store)
{
if sshError.isProxyAuthentication {
connectionAttemptID = nil
requestProxyAuthentication(
for: jump, using: store,
failureMessage: sshError.localizedDescription,
mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
return
}
let identity = store.identity(for: jump)
let submitted = suppliedAuthentications[jump.id]
let retryNeedsPassphrase =
submitted?.hasPassphrase != true
&& (submitted?.keyID ?? identity?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
connectionAttemptID = nil
requestAuthentication(
for: jump,
username: submitted?.username ?? identity?.username ?? connectionRoute?.usernames[jump.id] ?? "",
selectedKeyID: submitted?.keyID ?? identity?.keyID,
failureMessage: sshError.localizedDescription,
requiresPassphrase: retryNeedsPassphrase,
preserveSubmission: retryNeedsPassphrase && submitted != nil
)
return
}
connectionAttemptID = nil
handleConnectionAttemptFailure(
error, store: store, reconnecting: reconnecting
)
return
}
connection.clearHostKeyPrompt()
connectionTask?.cancel()
status = reconnecting ? .reconnecting : .connecting
let authenticationReference: SSHAuthenticationReference
if let identityID = suppliedAuthentication?.identityID ?? (suppliedAuthentication == nil ? identity?.id : nil) {
authenticationReference = SSHAuthenticationReference(
identityID: identityID,
username: username,
permitsNone: identity?.hasSecret == false,
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|| suppliedAuthentication?.hasPassphrase == true
)
} else {
authenticationReference = SSHAuthenticationReference(
fallbackKeyID: selectedKeyID,
username: username,
permitsNone: selectedKeyID == nil && suppliedAuthentication?.hasPassword != true,
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|| suppliedAuthentication?.hasPassphrase == true
)
}
connectionTask = Task { [weak self, weak store] in
guard let self, let store else { return }
do {
try await openSSH(
host: host, username: username, credentials: credentials,
expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps,
authenticationReference: authenticationReference, attemptID: attemptID,
authorizeHostKey: { [weak self] challenge in
guard let self else { throw CancellationError() }
return await self.connection.authorizeHostKey(challenge)
}
)
} catch is CancellationError {
guard connectionAttemptID == attemptID else { return }
connectionAttemptID = nil
status = .disconnected
connection.stopRecovery()
connectionJourney.fail(String(localized: AppStrings.Connections.authenticationCancelled))
} catch {
guard connectionAttemptID == attemptID else { return }
connectionAttemptID = nil
if let sshError = error as? TermindSSHError,
sshError.shouldRequestAuthentication
{
let authenticationHost: Host
if let id = sshError.routeHostID, let jump = connectionHost(id, using: store) {
authenticationHost = jump
} else {
authenticationHost = host
}
let authenticationIdentity = store.identity(for: authenticationHost)
if sshError.isProxyAuthentication {
requestProxyAuthentication(
for: authenticationHost, using: store,
failureMessage: sshError.localizedDescription,
mode: sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
return
}
let submitted = suppliedAuthentications[authenticationHost.id]
let authenticationKeyID =
submitted?.keyID
?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil)
let retryNeedsPassphrase =
submitted?.requiresPasswordReentry != true
&& submitted?.hasPassphrase != true
&& authenticationKeyID.flatMap { store.key($0) }?.hasPassphrase == true
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
requestAuthentication(
for: authenticationHost,
username: submitted?.username ?? authenticationIdentity?.username
?? connectionRoute?.usernames[authenticationHost.id]
?? (authenticationHost.id == host.id ? username : ""),
selectedKeyID: authenticationKeyID,
failureMessage: (credentialFailures
+ [sshError.localizedDescription]).joined(separator: "\n"),
requiresPassphrase: retryNeedsPassphrase,
mode: !returnsToIdentity && sshError.isServerAuthenticationChallenge
? .serverChallenge : .localIdentity,
preserveSubmission: retryNeedsPassphrase && submitted != nil
)
} else {
handleConnectionAttemptFailure(
error, store: store, reconnecting: reconnecting
)
}
}
}
}
func continueAuthentication(
identityID: Identity.ID?, username: String, password: String?, passphrase: String?,
keyID: SSHKeyRef.ID?, save: Bool,
using store: any RemoteSessionHostStore
) {
let selectedIdentity = identityID.flatMap { store.identity($0) }
let trimmedUsername = ((pendingAuthentication?.isProxy == true ? nil : selectedIdentity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUsername.isEmpty else { return }
guard let prompt = pendingAuthentication,
var host = connectionHost(prompt.host.id, using: store)
else { return }
if prompt.isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: trimmedUsername, password: password, identityID: selectedIdentity?.id)
do {
if save {
host = try store.saveProxyAuthentication(
submission, for: host, selectedIdentity: selectedIdentity,
suppliedPassword: password)
}
suppliedProxyAuthentications[host.id] = submission
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
requestProxyAuthentication(
for: host, using: store, failureMessage: error.localizedDescription)
}
return
}
if prompt.requiresPassphrase {
do {
suppliedAuthentications[host.id] = try store.passphraseSubmission(
for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save)
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
pendingAuthentication = failed
}
return
}
let submittedKeyID = selectedIdentity?.keyID ?? keyID
do {
let submission = try store.authenticationSubmission(
username: trimmedUsername, password: password, passphrase: passphrase,
keyID: submittedKeyID, selectedIdentity: selectedIdentity
)
suppliedAuthentications[host.id] = submission
if host.id == hostID { usernameHint = trimmedUsername }
connectionRoute?.usernames[host.id] = trimmedUsername
pendingAuthentication = nil
connectionJourney.resume()
if save {
if let route = connectionRoute {
let saved = try store.saveConnectionRoute(
route, authentication: submission, authenticationHostID: host.id, suppliedPassword: password
)
adoptSavedRoute(saved, replacing: route, using: store)
} else {
host = try store.saveAuthentication(
submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password
)
if host.id == hostID { transientHost = host }
}
}
} catch {
requestAuthentication(
for: host, username: trimmedUsername,
selectedKeyID: submittedKeyID, failureMessage: error.localizedDescription,
requiresPassphrase:
submittedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true
)
return
}
connect(using: store)
}
func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) {
connection.trustHostKey(observation, using: store)
}
func continueWithTemporaryHostKey(_ observation: SSHHostKeyObservation) {
_ = connection.useTemporaryHostKey(observation)
}
func disconnect() {
commandMonitor.endConnection()
startsOnMount = false
resetCommandGeneratorShellState()
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferSnapshot = nil
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
commandExcludedFromHistory = nil
pendingAuthentication = nil
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
connectionAttemptID = nil
connection.disconnect()
connectionTask?.cancel()
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
status = .disconnected
}
func retryReconnectNow() {
reconnectController.retryNow()
}
func stopReconnecting() {
guard let reconnectState else { return }
remoteWindowTitle = nil
connection.stopRecovery()
connectionAttemptID = nil
connectionTask?.cancel()
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
status = .failed(reconnectState.lastError)
connectionJourney.fail(reconnectState.lastError)
}
private func openSSH(
host: Host, username: String, credentials: [SSHCredentials],
expectedHostKeys: Set<SSHHostKeyIdentity>,
proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
authenticationReference: SSHAuthenticationReference, attemptID: UUID,
authorizeHostKey: @escaping SSHHostKeyAuthorizer
) async throws {
let wasReconnecting = connection.isRecovering
let presentsConnectionJourney = connectionJourney.isPresented
let configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentials, expectedHostKeys: expectedHostKeys,
proxy: proxy, jumps: jumps,
charset: host.charset,
authenticationReference: authenticationReference
)
let terminalSurface = terminalSurface
commandMonitor.beginConnection()
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
output: { data in terminalSurface.receive(data) },
exit: { [weak self] result in
Task { @MainActor in
guard let self, self.connectionAttemptID == attemptID else { return }
self.handleRemoteExit(result)
}
},
shellIntegration: { [weak self] event in
Task { @MainActor in
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveShellIntegrationEvent(event)
}
}
)
ssh = actor
do {
await actor.configureEnvironment(host.environmentVariables) { variables in
let list = variables.joined(separator: ", ")
let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list))
terminalSurface.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8))
}
try await actor.connect(
using: SSHConnectionRequest(
configuration: configuration,
authorizeHostKey: authorizeHostKey,
keyboardInteractive: { [weak self] challenge in
guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() }
return try await self.connection.answerKeyboardInteractive(challenge)
},
securityKeySigning: { [weak self] request in
guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() }
return try await self.connection.signWithSecurityKey(request)
},
hostKeyConstraints: connectionOrigin.hostKeyConstraints
),
shellIntegrationEnabled: isShellIntegrationEnabled,
columns: latestViewport.columns, rows: latestViewport.rows,
progress: progressHandler(attemptID: attemptID)
)
try Task.checkCancellation()
guard connectionAttemptID == attemptID else { throw CancellationError() }
transferSnapshot =
statusStore?.additionalConnectionSnapshot(from: configuration)
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
temporaryHostKeys.removeAll()
_ = connection.markConnected()
status = .connected
if presentsConnectionJourney {
connectionJourney.succeed()
} else {
connectionJourney.hide()
}
if wasReconnecting {
appendTerminalNotice(
String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted)
)
}
if !isShellIntegrationEnabled {
isInitialCommandSubmissionReady = true
}
submitPendingInitialCommandIfReady()
} catch {
await actor.disconnect()
if ssh === actor { ssh = nil }
throw error
}
}
/// A normal remote-shell exit owns the pane lifecycle, not the reusable
/// Ghostty surface lifecycle. Marking the in-memory backend as finished
/// makes the surface permanently stop producing input, which is incorrect
/// for Termind's retained pane model. Close the pane through its current
/// Workspace instead; transport failures remain visible for reconnection.
func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferSnapshot = nil
connectionAttemptID = nil
ssh = nil
switch result {
case .success:
connection.resetRecovery()
status = .disconnected
inputRouter?.closeSSHSessionPane(afterExit: self)
case .failure(let error):
beginAutomaticReconnect(after: error.localizedDescription)
}
}
private func handleConnectionAttemptFailure(
_ error: any Error, store: any RemoteSessionHostStore, reconnecting: Bool
) {
connectionAttemptID = nil
if connection.hasConnectedOnce || reconnecting {
scheduleReconnect(after: error.localizedDescription, using: store)
} else {
status = .failed(error.localizedDescription)
connectionJourney.fail(
error.localizedDescription, at: (error as? TermindSSHError)?.routeHostID
)
}
}
private func beginAutomaticReconnect(after message: String) {
guard connection.hasConnectedOnce, let store = statusStore else {
status = .failed(message)
return
}
appendTerminalNotice(String(localized: AppStrings.Connections.connectionLostReconnectingProgress))
scheduleReconnect(after: message, using: store)
}
private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
connectionAttemptID = nil
connectionTask?.cancel()
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
transferSnapshot = nil
status = .reconnecting
let host = connectionHost(hostID, using: store)
if let connectionRoute, let host {
connectionJourney.begin(
host: host, route: connectionRoute.hosts, purpose: .sshSession,
username: connectionRoute.usernames[host.id], mode: .reconnection
)
} else {
connection.presentReconnectJourney(
host: host, using: store, purpose: .sshSession,
username: host.flatMap { store.identity(for: $0)?.username } ?? usernameHint
)
}
connection.scheduleRecovery(after: message) { [weak self, weak store] in
guard let self, let store else { return }
self.startConnection(using: store)
}
}
private func appendTerminalNotice(_ message: String) {
terminalSurface.appendNotice(message)
}
private func requestAuthentication(
for host: Host, username: String,
selectedKeyID: SSHKeyRef.ID?, failureMessage: String? = nil,
requiresPassphrase: Bool = false,
mode: SSHAuthenticationPrompt.Mode = .localIdentity,
preserveSubmission: Bool = false
) {
guard let store = statusStore else { return }
connection.pauseForUserInteraction()
if !preserveSubmission { suppliedAuthentications[host.id] = nil }
pendingAuthentication = SSHAuthenticationPromptResolver.host(
host, using: store,
submission: preserveSubmission ? suppliedAuthentications[host.id] : nil,
username: username, selectedKeyID: selectedKeyID,
failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil,
mode: mode, requiresPassphrase: requiresPassphrase,
allowsSaving: connectionOrigin.allowsPersistence
)
status = .disconnected
switch mode {
case .localIdentity:
if requiresPassphrase {
connectionJourney.waitForLocalPassphrase(
at: host.id, failure: failureMessage
)
} else {
connectionJourney.waitForLocalIdentity(
at: host.id, failure: failureMessage
)
}
case .serverChallenge:
connectionJourney.waitForAuthentication(at: host.id, failure: failureMessage)
}
}
private func requestProxyAuthentication(
for host: Host, using store: any RemoteSessionHostStore,
failureMessage: String? = nil,
mode: SSHAuthenticationPrompt.Mode = .localIdentity
) {
guard host.proxy != nil else { return }
connection.pauseForUserInteraction()
let staged = suppliedProxyAuthentications[host.id]
suppliedProxyAuthentications[host.id] = nil
pendingAuthentication = SSHAuthenticationPromptResolver.proxy(
for: host, using: store, submission: staged,
failureMessage: failureMessage, mode: mode,
allowsSaving: connectionOrigin.allowsPersistence)
status = .disconnected
connectionJourney.waitForProxyAuthentication(at: host.id, failure: failureMessage)
}
private func progressHandler(attemptID: UUID) -> SSHRouteProgressHandler {
connection.progressHandler(attemptID: attemptID) { [weak self] id in
self?.connectionAttemptID == id
}
}
func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? {
connectionRoute?.host(id) ?? store.host(id) ?? (id == hostID ? transientHost : nil)
}
private func adoptSavedRoute(
_ saved: SSHConnectionRoute, replacing route: SSHConnectionRoute, using store: any RemoteSessionHostStore
) {
let mapping = Dictionary(uniqueKeysWithValues: zip(route.hosts, saved.hosts).map { ($0.id, $1.id) })
suppliedAuthentications = Dictionary(
uniqueKeysWithValues: suppliedAuthentications.map {
(mapping[$0.key] ?? $0.key, $0.value)
})
suppliedProxyAuthentications = Dictionary(
uniqueKeysWithValues: suppliedProxyAuthentications.map {
(mapping[$0.key] ?? $0.key, $0.value)
})
temporaryHostKeys = Dictionary(
uniqueKeysWithValues: temporaryHostKeys.map {
(mapping[$0.key] ?? $0.key, $0.value)
})
if let destination = saved.hosts.last {
if hostID != destination.id {
store.setConnectionStatus(.disconnected, connectionID: id, hostID: hostID)
}
hostID = destination.id
transientHost = destination
usernameHint = saved.usernames[destination.id]
}
connectionRoute = saved
}
func copyForReconnect() -> SSHSessionModel {
let host = transientHost ?? Host(id: hostID, name: title, host: title)
let copy = SSHSessionModel(host: host, usernameHint: usernameHint)
copy.connectionRoute = connectionRoute
copy.connectionOrigin = connectionOrigin
copy.connectionCoordinator = connectionCoordinator
copy.commandHistoryStore = commandHistoryStore
return copy
}
/// Routes terminal-produced bytes only when they came from an explicitly
/// captured user interaction. Mouse/focus reports and protocol replies use
/// the direct path even while Broadcast Input is enabled.
func handleTerminalWrite(_ data: Data, isInteractiveInput: Bool) {
if isInteractiveInput { invalidateCommandGeneration() }
if isInteractiveInput, let inputRouter {
inputRouter.routeInteractiveInput(data, from: self)
} else {
writeInputDirectly(data)
}
}
/// The terminal side of the routing boundary. Broadcast targets call this
/// rather than feeding bytes back through the router, preventing recursion.
func writeInputDirectly(_ data: Data) {
if let inputSink {
inputSink(data)
return
}
guard let ssh else { return }
Task { await ssh.write(data) }
}
private func resizeSSH(_ viewport: InMemoryTerminalViewport) {
latestViewport = viewport
guard let ssh else { return }
Task {
await ssh.resize(
columns: viewport.columns, rows: viewport.rows,
widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels
)
}
}
/// Ghostty parses OSC 7 and publishes the decoded remote path on its view
/// state. Bridge that dependency-owned state into the sshSession so every Termind
/// surface reads one observable source of truth.
private func observeWorkingDirectory(
from state: TerminalViewState,
through relay: SSHSessionRelay
) {
workingDirectoryObservation = state.$workingDirectory.sink { [weak relay] path in
Task { @MainActor in
relay?.owner?.workingDirectory = path?.isEmpty == false ? path : nil
}
}
}
/// Mirrors Ghostty's parsed OSC 0/2 title into the SSH connection's
/// lifecycle. `TerminalViewState.title` deliberately outlives a transport;
/// the copy does not, preventing a reconnected shell from inheriting the
/// previous shell or foreground program's menu-bar title.
private func observeRemoteWindowTitle(
from state: TerminalViewState,
through relay: SSHSessionRelay
) {
remoteWindowTitleObservation = state.$title.sink { [weak relay] title in
MainActor.assumeIsolated {
relay?.owner?.remoteWindowTitle = RemoteWindowTitle.sanitized(title)
}
}
}
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
guard isShellIntegrationEnabled else { return }
commandMonitor.receiveShellEvent(event)
switch event {
case .available(let shell, let systemInfo, let homeDirectory):
if let homeDirectory, homeDirectory.hasPrefix("/") {
homeResolution = .resolved(RemotePath.normalized(homeDirectory))
} else {
homeResolution = .unavailable
}
integratedShell = shell
remoteSystemInfo = systemInfo
statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID)
isShellPromptReady = false
shellIntegrationAvailable = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory)
submitPendingInitialCommandIfReady()
case .systemInfo(let systemInfo):
remoteSystemInfo = systemInfo
statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID)
case .homeResolutionUnavailable:
homeResolution = .unavailable
inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory)
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .promptReady:
isShellPromptReady = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
submitPendingInitialCommandIfReady()
case .commandStarted(let command):
isShellPromptReady = false
invalidateCommandGeneration()
activeShellCommand = ActiveShellCommand(
historyID: recordExecutedCommand(command)
)
case .commandFinished(let status, let duration):
recordCommandCompletion(exitStatus: status, reportedDuration: duration)
case .draft(let draft):
commandDraft.receive(draft)
}
}
/// Submits an explicit one-shot launch command, or this connection's freshly
/// resolved Host startup command. Keeping the History exclusion next to the
/// write ensures it is armed only when the command actually runs.
func submitPendingInitialCommand() {
let initialCommand: PendingInitialCommand
if let explicit = pendingInitialCommand {
initialCommand = explicit
pendingInitialCommand = nil
} else if let startup = pendingConnectionStartupCommand {
initialCommand = startup
pendingConnectionStartupCommand = nil
} else {
return
}
if !initialCommand.recordsInHistory {
commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations(
in: initialCommand.command
)
}
insert(initialCommand.command, submit: true)
}
private func submitPendingInitialCommandIfReady() {
guard status.isLive, isInitialCommandSubmissionReady else { return }
submitPendingInitialCommand()
}
@discardableResult
func recordExecutedCommand(_ command: String) -> CommandHistoryEntry.ID? {
let command = CommandHistoryEntry.normalizingLineContinuations(in: command)
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
if command == commandExcludedFromHistory {
commandExcludedFromHistory = nil
return nil
}
if statusStore?.host(hostID) != nil {
return commandHistoryStore?.record(command, for: hostID)?.id
} else {
let entry = CommandHistoryEntry(hostID: hostID, command: command)
transientCommandHistory.insert(entry, at: 0)
if transientCommandHistory.count > CommandHistoryModel.perHostLimit {
transientCommandHistory.removeLast(
transientCommandHistory.count - CommandHistoryModel.perHostLimit
)
}
return entry.id
}
}
private func recordCommandCompletion(
exitStatus: Int32, reportedDuration: TimeInterval?
) {
guard let active = activeShellCommand else { return }
activeShellCommand = nil
guard let historyID = active.historyID else { return }
if statusStore?.host(hostID) != nil {
commandHistoryStore?.recordCompletion(
for: historyID, hostID: hostID,
exitStatus: exitStatus, duration: reportedDuration
)
} else if let index = transientCommandHistory.firstIndex(where: {
$0.id == historyID
}) {
transientCommandHistory[index].exitStatus = exitStatus
transientCommandHistory[index].duration = reportedDuration
}
}
func deleteTransientHistory(_ id: CommandHistoryEntry.ID) {
transientCommandHistory.removeAll { $0.id == id }
}
func clearTransientHistory() {
transientCommandHistory.removeAll()
}
}
private final class SSHSessionRelay: @unchecked Sendable {
weak var owner: SSHSessionModel?
}
// MARK: - Demo data
extension SSHSessionModel {
/// A connected-looking session with a real terminal surface and synthetic,
/// non-sensitive output. Screenshot mode uses this instead of opening a
/// network connection or reading somebody's actual scrollback.
static func demo(host: Host, transcript: String? = nil) -> SSHSessionModel {
let sshSession = SSHSessionModel(host: host)
sshSession.status = .connected
sshSession.workingDirectory = "/var/log/nginx"
sshSession.homeResolution = .resolved("/home/demo")
sshSession.shellIntegrationAvailable = true
sshSession.integratedShell = "zsh"
sshSession.isShellPromptReady = true
let bytes = Data((transcript ?? demoTranscript(for: host.name)).utf8)
// Drive the same delegate callback that OSC 7 Shell Integration emits.
// Feed the screen only after the native surface has mounted and sized;
// writing into the default 80-column grid first leaves Ghostty's cursor
// behind when a narrow demo pane reflows that content.
Task { [weak sshSession] in
try? await Task.sleep(for: .milliseconds(350))
sshSession?.terminalSurface.receive(Data("\u{001B}[2J\u{001B}[H".utf8))
sshSession?.terminalSurface.receive(bytes)
// The demo has no remote PTY to synchronize Ghostty's interactive
// cursor with received output. Hide that cursor for static captures
// instead of advertising a caret at a position no shell reported.
sshSession?.terminalSurface.receive(Data("\u{001B}[?25l".utf8))
sshSession?.terminalState.terminalDidChangeWorkingDirectory("/var/log/nginx")
}
return sshSession
}
private static func demoTranscript(for host: String) -> String {
let prompt =
"\u{001B}[36mdeploy@\(host)\u{001B}[0m "
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m %"
return [
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
prompt + " systemctl status nginx --no-pager",
"● nginx.service - A high performance web server",
" Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)",
" Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC",
prompt + " tail -n 3 access.log",
"10.0.4.21 - - [27/Aug/2026:09:41:12] \"GET /health HTTP/1.1\" 200 18",
"10.0.4.18 - - [27/Aug/2026:09:41:14] \"GET /api/status HTTP/1.1\" 200 52",
prompt + " ",
].joined(separator: "\r\n")
}
}