Files

1690 lines
77 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
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 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: RemotePathDisplaying, Identifiable {
let commandHistory = CommandHistoryModel()
var id: UUID { commandHistory.sessionID }
/// 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 transport: HostTerminalProtocol = .ssh
private(set) var transportState: ResumableTerminalState?
var status: ConnectionStatus = .disconnected {
didSet {
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
synchronizeResizeThrottle()
}
}
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)
}
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
var hasUnreadOutput: Bool = false
/// 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?
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?
let terminalContext = TerminalContextCoordinator()
private var hasRuntimeContext = false
private var historyCharset: RemoteCharset = .utf8
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
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? {
get { connection.pendingAuthentication }
set { connection.pendingAuthentication = newValue }
}
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)?
private var ssh: SSHPTYChannel?
var connectionCoordinator: RemoteConnectionCoordinator?
private var connectionTask: Task<Void, Never>?
private var startsOnMount = true
private(set) var connectionAttemptID: UUID? {
get { connection.connectionAttemptID }
set {
let oldValue = connection.connectionAttemptID
connection.connectionAttemptID = newValue
if newValue != oldValue {
terminalUpload.cancelPendingUpload()
commandHistory.cancelLoading()
}
}
}
@ObservationIgnored lazy var terminalUpload = TerminalUploadModel(session: self)
private var reconnectController: SessionReconnectController { connection.reconnect }
private var workingDirectoryObservation: AnyCancellable?
private var terminalActivityObservation: AnyCancellable?
private(set) var isTerminalActive = false
/// 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 persistentSessionSnapshot: SSHAdditionalConnectionSnapshot?
var isRemoteSessionClosing = false
var restorableRemoteSession: RemoteSessionReference? {
isRemoteSessionClosing ? nil : sessionPersistence.attachment
}
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var connectionRoute: SSHConnectionRoute? {
get { connection.connectionRoute }
set { connection.connectionRoute = newValue }
}
var connectionOrigin: RemoteConnectionOrigin = .library
var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence }
private var transientHost: Host? {
get { connection.transientHost }
set { connection.transientHost = newValue }
}
private var usernameHint: String?
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] {
get { connection.suppliedAuthentications }
set { connection.suppliedAuthentications = newValue }
}
private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] {
get { connection.suppliedProxyAuthentications }
set { connection.suppliedProxyAuthentications = newValue }
}
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
}
let sessionPersistence = RemoteSessionCoordinator()
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
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
var reconnectState: ReconnectState? { connection.reconnectState }
var acceptsTerminalInput: Bool {
!transport.isResumable
|| (!connectionJourney.isPresented
&& transportState != .recovering && transportState != .suspended
&& transportState != .failed && transportState != .ended && transportState != .closed)
}
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.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
self.transientHost = transientHost
relay.owner = self
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
observeWorkingDirectory(from: surface.state, through: relay)
terminalActivityObservation = surface.state.$isFocused
.combineLatest(surface.state.$isSurfaceVisible)
.map { $0 && $1 }
.removeDuplicates()
.map { _ in () }
.merge(
with: NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification).map { _ in () },
NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification).map { _ in () }
)
.sink { [weak relay] _ in
Task { @MainActor in relay?.owner?.synchronizeTerminalActivity() }
}
sessionPersistence.didChangePrompt = { [weak self] pending in
if pending { self?.connectionJourney.waitForSessionSelection() } else { self?.connectionJourney.resume() }
}
}
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() {
guard !connectionJourney.isPresented, sessionPersistence.prompt == nil else { return }
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) {
if let statusStore, let host = statusStore.host(hostID),
(try? statusStore.requireHostAccess(host)) == nil
{
return
}
invalidateCommandGeneration()
writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8))
}
/// Runtime prompt snapshots can outlive foreground ownership or tmux copy/prefix mode.
/// A read-draft ticket confirms that the prompt is still interactive.
private func synchronizeResizeThrottle() {
let milliseconds: Double
if !status.isLive || !isShellIntegrationEnabled || !shellIntegrationAvailable {
milliseconds = 35
} else if isShellPromptReady,
!hasRuntimeContext || terminalContext.ticket(requiring: .readDraft) != nil
{
milliseconds = 0
} else {
milliseconds = 128
}
terminalSurface.setResizeThrottle(milliseconds: milliseconds)
}
var canGenerateFromCurrentDraft: Bool {
status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady
&& TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "")
&& (!hasRuntimeContext || terminalContext.ticket(requiring: [.readDraft, .replaceDraft]) != nil)
}
func requestCurrentDraft() async throws -> String {
if hasRuntimeContext {
guard let ticket = terminalContext.ticket(requiring: .readDraft),
let context = terminalContext.active, let ssh
else { throw TerminalDraftError.unavailable }
let value = try await ssh.contextDraft(context)
guard terminalContext.isCurrent(ticket, requiring: .readDraft) else {
throw TerminalDraftError.unavailable
}
return value
}
return 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 insertUploadedPaths(_ paths: [String]) {
let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " "
if hasRuntimeContext {
replaceCurrentDraft(with: text)
return
}
// Write only to this session: broadcast input must not copy local upload paths.
terminalViewOwner.pasteUploadedText(text)
}
var directoryContextTicket: TerminalContextCoordinator.Ticket? {
terminalContext.ticket(requiring: .directory)
}
func confirmDisplayedDirectory(_ path: String) async -> Bool {
guard status.isLive, workingDirectory == path else { return false }
guard hasRuntimeContext else { return true }
guard let context = terminalContext.active, let ssh else { return false }
let generation = terminalContext.generation
do {
try await ssh.confirmContext(context)
return terminalContext.generation == generation && terminalContext.active == context
&& workingDirectory == path
} catch {
return false
}
}
func isDirectoryContextCurrent(_ ticket: TerminalContextCoordinator.Ticket?) -> Bool {
guard hasRuntimeContext else { return ticket == nil }
guard let ticket else { return false }
return terminalContext.isCurrent(ticket, requiring: .directory)
}
func confirmDirectoryContext(_ ticket: TerminalContextCoordinator.Ticket?) async throws {
guard hasRuntimeContext else { return }
guard isDirectoryContextCurrent(ticket), let context = terminalContext.active, let ssh else {
throw TerminalDraftError.unavailable
}
do {
try await ssh.confirmContext(context)
guard isDirectoryContextCurrent(ticket) else { throw TerminalDraftError.unavailable }
} catch {
if isDirectoryContextCurrent(ticket) { receiveRuntimeContext(.init(status: .unknown)) }
throw error
}
}
func replaceCurrentDraft(with command: String) {
if hasRuntimeContext {
guard let ticket = terminalContext.ticket(requiring: .replaceDraft),
let context = terminalContext.active, let ssh
else { return }
Task { [weak self] in
guard let self, terminalContext.isCurrent(ticket, requiring: .replaceDraft) else { return }
do {
_ = try await ssh.contextDraft(context, replacing: command)
} catch {
if terminalContext.isCurrent(ticket, requiring: .replaceDraft) {
receiveRuntimeContext(.init(status: .unknown))
}
}
}
return
}
writeInputDirectly(commandDraft.replacementPayload(for: command))
}
private func invalidateCommandGeneration() {
commandGeneratorRevision &+= 1
inputRouter?.sshSessionDidInvalidateCommandGeneration(self)
}
private func resetCommandGeneratorShellState() {
terminalContext.reset()
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
hasRuntimeContext = false
contextHistory.removeAll()
integratedShell = nil
remoteSystemInfo = nil
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
initialCommandStartupTicket = nil
pendingConnectionStartupCommand = nil
activeShellCommand = nil
commandDraft.cancel()
invalidateCommandGeneration()
synchronizeResizeThrottle()
}
func connectIfNeeded(using store: any RemoteSessionHostStore) {
guard startsOnMount, status == .connecting else { return }
connect(using: store)
}
func connect(using store: any RemoteSessionHostStore) {
if transport.isResumable, let ssh {
Task { try? await ssh.retryTransport() }
return
}
startsOnMount = false
startConnection(using: store)
}
private func startConnection(using store: any RemoteSessionHostStore) {
commandMonitor.endConnection()
resetCommandGeneratorShellState()
homeResolution = .unavailable
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
workingDirectory = nil
transferSnapshot = nil
commandExcludedFromHistory = nil
statusStore = store
let attemptID = UUID()
connectionAttemptID = attemptID
transportState = nil
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
}
guard host.isEnabled(transport) else {
startsOnMount = false
status = .failed(TerminalTransportError.unsupportedRoute.localizedDescription)
connectionJourney.fail(TerminalTransportError.unsupportedRoute.localizedDescription)
return
}
isShellIntegrationEnabled =
transport != .mosh
&& 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
?? (host.isTailscaleManaged ? store.savedTailscaleUsername(for: host) : identity?.username)
?? usernameHint
connectionJourney.begin(
host: host, route: route, purpose: .terminal(transport), 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 store.resolveHostAccess(host, route: connectionRoute)
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.connectionCancelled))
} 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
) {
connection.continueAuthentication(
identityID: identityID, username: username, password: password, passphrase: passphrase,
keyID: keyID, save: save, destinationID: hostID, using: store
) { host, submission in
if host.id == hostID { usernameHint = submission.username }
connectionRoute?.usernames[host.id] = submission.username
} saveIdentity: { host, submission, selectedIdentity in
if let route = connectionRoute {
let saved = try store.saveConnectionRoute(
route, authentication: submission, authenticationHostID: host.id, suppliedPassword: password)
adoptSavedRoute(saved, replacing: route, using: store)
} else {
let saved = try store.saveAuthentication(
submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password)
if saved.id == hostID { transientHost = saved }
}
} onFailure: { failure in
switch failure {
case .proxy(let host, let error):
requestProxyAuthentication(for: host, using: store, failureMessage: error.localizedDescription)
case .identity(let host, let username, let keyID, _, let error):
requestAuthentication(
for: host, username: username, selectedKeyID: keyID,
failureMessage: error.localizedDescription,
requiresPassphrase:
keyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true)
}
} connect: {
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() {
sessionPersistence.cancel()
commandHistory.cancelLoading()
terminalUpload.cancelPendingUpload()
commandMonitor.endConnection()
startsOnMount = false
resetCommandGeneratorShellState()
workingDirectory = nil
homeResolution = .unavailable
transferSnapshot = nil
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
commandExcludedFromHistory = nil
connectionAttemptID = nil
connection.disconnect()
connectionTask?.cancel()
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
if transport.isResumable { transportState = .closed }
status = .disconnected
}
func retryReconnectNow() {
if transport.isResumable {
guard transportState == .recovering, let ssh else { return }
let attemptID = connectionAttemptID
Task { [weak self] in
do {
try await ssh.retryTransport()
} catch {
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
}
reconnectController.retryNow()
}
func stopReconnecting() {
guard let reconnectState else { return }
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
var 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
)
guard let store = statusStore else { throw TailscaleConfigurationError.unavailable }
configuration.accessHostIDs =
jumps.map(\.id) + [host.id] + (connectionRoute.map { Array($0.sourceHostIDs.values) } ?? [])
configuration.accessCheck = store.connectionAccessCheck(for: configuration.accessHostIDs)
configuration.beforeConnect = store.connectionWakeAction(for: configuration.accessHostIDs)
configuration.network = try store.tailscaleNetwork(
for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host)
configuration.tailscaleNodeID = host.tailscale?.stableNodeID
let terminalSurface = terminalSurface
commandMonitor.beginConnection()
commandMonitor.requireScopedEvents()
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
historyCharset = host.charset
do {
if transport.isResumable {
let snapshot = store.additionalConnectionSnapshot(from: configuration)
let requestFactory: @Sendable (Bool) async throws -> SSHConnectionRequest = {
[weak self, weak store] authenticatingTarget in
guard let self, let store, await self.connectionAttemptID == attemptID else {
throw CancellationError()
}
return try await store.resumableRouteRequest(
from: snapshot, 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: self.connectionOrigin.hostKeyConstraints,
authenticatingTarget: authenticatingTarget)
}
await actor.configureTransport(
transport, options: host.terminalTransports,
requestFactory: requestFactory,
stateChanged: { [weak self] state in
Task { @MainActor in
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveTransportState(state)
}
},
routeChanged: { [weak self] route in
Task { @MainActor in
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveTransportRoute(route)
}
})
}
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))
}
let persistence = sessionPersistence
let explicit = pendingInitialCommand?.command
let startup = explicit ?? pendingConnectionStartupCommand?.command
let integration = isShellIntegrationEnabled
if host.sessionPersistence.enabled || persistence.attachment != nil || persistence.requiresSelection {
persistence.didChangeAttachment = { [weak self] reference in
guard let self, self.connectionAttemptID == attemptID, reference != nil else { return }
self.pendingInitialCommand = nil
self.pendingConnectionStartupCommand = nil
}
await actor.configureStartup { execute in
try await persistence.prepare(
preferences: host.sessionPersistence, registry: .live, execute: execute,
hostID: host.id, endpoint: "\(host.host):\(host.sshPort)", username: username,
startupCommand: startup, requiresNewSession: explicit != nil, shellIntegration: integration,
environment: host.environmentVariables)
}
}
await actor.setTerminalActive(isTerminalActive)
guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() }
if transport == .ssh {
// A retained surface may not emit another resize for this new channel.
// Prime all dimensions before connect consumes the worker's initial viewport.
let viewport = latestViewport
await actor.resize(
columns: viewport.columns, rows: viewport.rows,
widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels)
}
guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() }
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: connection.progressHandler(attemptID: attemptID)
)
try Task.checkCancellation()
guard connectionAttemptID == attemptID else { throw CancellationError() }
guard transportState != .ended else { return }
if transportState == .recovering || transportState == .suspended || transportState == .failed { return }
transferSnapshot =
statusStore?.additionalConnectionSnapshot(from: configuration)
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
persistentSessionSnapshot = statusStore?.additionalConnectionSnapshot(from: configuration)
suppliedAuthentications.removeAll()
suppliedProxyAuthentications.removeAll()
temporaryHostKeys.removeAll()
_ = connection.markConnected()
status = .connected
if transport == .ssh {
if hasRuntimeContext, terminalContext.active?.historySource != nil {
loadContextHistory()
} else {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
}
}
if sessionPersistence.attachment != nil {
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
homeResolution = .unavailable
}
if presentsConnectionJourney, connectionJourney.phase != .succeeded {
connectionJourney.succeed()
} else if !presentsConnectionJourney {
connectionJourney.hide()
}
if wasReconnecting, transport == .ssh, sessionPersistence.attachment == nil {
appendTerminalNotice(
String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted)
)
}
if !isShellIntegrationEnabled {
isInitialCommandSubmissionReady = true
}
submitPendingInitialCommandIfReady()
} catch {
await actor.disconnect()
if ssh === actor { ssh = nil }
throw error
}
}
func receiveTransportRoute(_ route: TerminalRouteSnapshot) {
guard transportState != .ended, transportState != .closed else { return }
prepareTransportRecoveryJourney()
connectionJourney.receiveTransportRoute(route)
}
func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) {
guard transportState != .ended, transportState != .closed else { return }
guard state != transportState || failure != nil else { return }
transportState = state
let context = connection.transportJourneyContext()
switch state {
case .connected:
status = .connected
if connectionJourney.transportRecovery != nil {
_ = connection.markConnected()
}
case .recovering, .suspended:
status = .reconnecting
case .ended:
// These protocols report session end, not the remote process's numeric exit status.
handleRemoteExit(.success(0))
case .failed:
connection.resetRecovery()
let message = failure ?? TerminalTransportError.sessionFailed.localizedDescription
status = .failed(message)
// Transport cleanup can follow a failure; the pane owns dismissing its Journey.
case .connecting, .closed: break
}
connectionJourney.receiveTransportState(
state, context: context, failure: failure,
recoveryMessage: { $0.statusMessage }, prepareRecovery: prepareTransportRecoveryJourney)
}
private func prepareTransportRecoveryJourney() {
if connectionJourney.endpoints.isEmpty {
let host =
connectionRoute?.host(hostID) ?? statusStore?.host(hostID) ?? transientHost
?? Host(id: hostID, name: title, host: title)
connectionJourney.begin(
host: host, route: connectionRoute?.hosts ?? [host], purpose: .terminal(transport),
username: connectionRoute?.usernames[hostID] ?? usernameHint, mode: .reconnection)
}
}
func takePersistentSessionCloseRequest(hosts: any RemoteSessionHostStore) -> RemoteSessionCloseRequest? {
guard let reference = sessionPersistence.attachment, reference.isManaged else { return nil }
let snapshot: SSHAdditionalConnectionSnapshot
if let persistentSessionSnapshot {
snapshot = persistentSessionSnapshot
} else {
guard let host = connectionRoute?.host(reference.hostID) ?? hosts.host(reference.hostID) ?? transientHost,
let restored = try? hosts.resumableControlSnapshot(for: host, username: reference.username)
else { return nil }
snapshot = restored
}
guard snapshot.hostID == reference.hostID,
"\(snapshot.target.host):\(snapshot.target.port)" == reference.endpoint
else { return nil }
let transferred = transport == .ssh ? ssh : nil
if transferred != nil { ssh = nil }
return RemoteSessionCloseRequest(
reference: reference, snapshot: snapshot,
connection: transferred.map { RemoteSessionCloseConnection(channel: $0) })
}
/// Normal shell exit closes the pane, not the reusable Ghostty surface.
/// Transport failures remain visible for reconnection.
func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
resetCommandGeneratorShellState()
workingDirectory = nil
terminalUpload.cancelPendingUpload()
if case .success(let exitCode) = result, let reference = sessionPersistence.attachment, let channel = ssh {
let attemptID = connectionAttemptID
Task { [weak self] in
guard let self else { return }
var sessionExists: Bool?
do {
let execute = try await channel.sessionCommandExecutor()
sessionExists = try await RemoteSessionBackendRegistry.live.sessionExists(
reference, execute: execute)
} catch {
// An unverified exit must not erase identity or start a replacement session.
}
guard self.connectionAttemptID == attemptID, self.sessionPersistence.attachment == reference else {
return
}
let shouldClose = self.sessionPersistence.finishExit(exitCode: exitCode, sessionExists: sessionExists)
await channel.disconnect()
guard self.connectionAttemptID == attemptID else { return }
self.ssh = nil
self.connection.resetRecovery()
self.status = .disconnected
if shouldClose {
self.inputRouter?.closeSSHSessionPane(afterExit: self)
return
}
self.appendTerminalNotice(
String(
localized: exitCode != 0
? AppStrings.SessionPersistence.failed
: self.sessionPersistence.detached
? AppStrings.SessionPersistence.detached : AppStrings.SessionPersistence.ended))
}
return
}
resetCommandGeneratorShellState()
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):
if transport.isResumable {
receiveTransportState(.failed, failure: error.localizedDescription)
} else {
beginAutomaticReconnect(after: error.localizedDescription)
}
}
}
private func handleConnectionAttemptFailure(
_ error: any Error, store: any RemoteSessionHostStore, reconnecting: Bool
) {
connectionAttemptID = nil
if transport.isResumable {
connection.resetRecovery()
status = .failed(error.localizedDescription)
connectionJourney.fail(error.localizedDescription)
return
}
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) {
connection.scheduleReconnect(
after: message, hostID: hostID, using: store,
purpose: .terminal(transport), route: connectionRoute, usernameHint: usernameHint
) {
resetCommandGeneratorShellState()
connectionAttemptID = nil
connectionTask?.cancel()
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
transferSnapshot = nil
status = .reconnecting
} retry: { [weak self] store in
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
if host.isTailscaleManaged {
connectionJourney.waitForTailscaleUsername(at: host.id, failure: failureMessage)
return
}
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)
}
func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? {
connection.connectionHost(id, destinationID: hostID, using: store)
}
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.transport = transport
copy.sessionPersistence.attachment = restorableRemoteSession
copy.connectionRoute = connectionRoute
copy.connectionOrigin = connectionOrigin
copy.connectionCoordinator = connectionCoordinator
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) {
guard !isInteractiveInput || acceptsTerminalInput else { return }
if isInteractiveInput { invalidateCommandGeneration() }
if isInteractiveInput, let inputRouter {
inputRouter.routeInteractiveInput(data, from: self)
} else {
writeInputDirectly(data, isInteractiveInput: isInteractiveInput)
}
}
/// 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, isInteractiveInput: Bool = true) {
guard !isInteractiveInput || acceptsTerminalInput else { return }
terminalContext.noteInput()
if let inputSink {
inputSink(data)
return
}
guard let ssh else { return }
Task { await ssh.write(data) }
}
private func resizeSSH(_ viewport: InMemoryTerminalViewport) {
guard viewport.columns > 0, viewport.rows > 0 else { return }
latestViewport = viewport
guard let ssh else { return }
let attemptID = connectionAttemptID
Task { [weak self] in
guard let self, self.ssh === ssh, self.connectionAttemptID == attemptID else { return }
let viewport = self.latestViewport
await ssh.resize(
columns: viewport.columns, rows: viewport.rows,
widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels
)
}
}
private func synchronizeTerminalActivity() {
// First responder can be retained in a non-key window. Window events must
// recheck native focus even when Ghostty's last published value is unchanged.
let active =
terminalState.isFocused && terminalState.isSurfaceVisible && terminalViewOwner.hasActiveKeyboardFocus
guard active != isTerminalActive else { return }
isTerminalActive = active
guard let ssh else { return }
Task { [weak self] in
guard let self, self.ssh === ssh, self.isTerminalActive == active else { return }
await ssh.setTerminalActive(active)
}
}
/// 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
guard let owner = relay?.owner, owner.isShellIntegrationEnabled, !owner.hasRuntimeContext else {
return
}
owner.workingDirectory = path?.isEmpty == false ? path : nil
}
}
}
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
guard isShellIntegrationEnabled else { return }
if case .ordinaryStartupUnavailable(let ticket) = event {
guard sessionPersistence.attachment == nil else { return }
guard let ticket else {
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
initialCommandStartupTicket = nil
return
}
initialCommandStartupTicket = ticket
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
return
}
if case .runtime(let update) = event {
receiveRuntimeContext(update)
return
}
guard !hasRuntimeContext else { return }
defer { synchronizeResizeThrottle() }
commandMonitor.receiveShellEvent(event)
switch event {
case .runtime, .ordinaryStartupUnavailable: break
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)
}
}
private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) {
defer { synchronizeResizeThrottle() }
hasRuntimeContext = true
let previous = terminalContext.revision
let events = terminalContext.receive(update, generation: terminalContext.generation)
loadContextHistory()
commandMonitor.receiveRuntimeEvents(events)
if previous != terminalContext.revision {
commandDraft.cancel()
terminalUpload.cancelPendingUpload()
invalidateCommandGeneration()
}
workingDirectory = terminalContext.directory
integratedShell = terminalContext.active?.shell
shellIntegrationAvailable = terminalContext.active?.capabilities.contains(.commandLifecycle) == true
isShellPromptReady = terminalContext.active?.promptReady == true
if let home = terminalContext.active?.home {
homeResolution = .resolved(RemotePath.normalized(home))
} else {
homeResolution = .unavailable
}
for event in events {
switch event.payload {
case .commandStarted(let command):
contextHistory[event.context] = recordExecutedCommand(command)
case .commandFinished(let status, let duration):
if let id = contextHistory.removeValue(forKey: event.context) {
commandHistory.recordCompletion(for: id, exitStatus: status, duration: duration)
}
case .draft(_, let value):
if terminalContext.active?.identity == event.context { commandDraft.receive(value) }
default: break
}
}
if update.status == .ordinary, isShellPromptReady {
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
}
}
private func loadContextHistory() {
let context = terminalContext.active
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
guard transport == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else { return }
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
}
/// 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
)
}
if let ticket = initialCommandStartupTicket {
initialCommandStartupTicket = nil
if let statusStore, let host = statusStore.host(hostID),
(try? statusStore.requireHostAccess(host)) == nil
{
return
}
let data = Data((initialCommand.command + "\r").utf8)
if let inputSink {
inputSink(data)
} else if let ssh {
Task { await ssh.submitInitialCommand(data, ticket: ticket) }
}
} else {
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
}
return commandHistory.record(command)?.id
}
private func recordCommandCompletion(
exitStatus: Int32, reportedDuration: TimeInterval?
) {
guard let active = activeShellCommand else { return }
activeShellCommand = nil
guard let historyID = active.historyID else { return }
commandHistory.recordCompletion(for: historyID, exitStatus: exitStatus, duration: reportedDuration)
}
}
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")
}
}