Distinguish SSH route admission from Telnet target admission and wire Telnet access and automatic Wake hooks. Await entitlement resolution for manual Wake and duplication before host or credential mutation. Use explicit protocol-extension forwarding overloads so existential single-argument calls cannot bypass the concrete admission witness. Remove dead paywall state and update obsolete quota/date test fixtures. Validation: 81 focused macOS and 58 focused iOS tests passed.
240 lines
8.9 KiB
Swift
240 lines
8.9 KiB
Swift
import Combine
|
|
import GhosttyTerminal
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@MainActor
|
|
private final class TelnetSessionRelay {
|
|
weak var owner: TelnetSessionModel?
|
|
}
|
|
|
|
@Observable
|
|
final class TelnetSessionModel: Identifiable {
|
|
let id = UUID()
|
|
let 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)
|
|
}
|
|
}
|
|
let commandMonitor = TerminalCommandMonitor()
|
|
private var commandObservation: TerminalCommandObservation?
|
|
var hasUnreadOutput = false
|
|
@ObservationIgnored lazy var find = TerminalFindController(
|
|
perform: { [weak self] in self?.performFind($0) },
|
|
focus: { [weak self] in self?.requestTerminalFocus() }
|
|
)
|
|
|
|
private let terminalSurface: PersistentTerminalViewOwner
|
|
var terminalState: TerminalViewState { terminalSurface.state }
|
|
var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface }
|
|
private weak var statusStore: (any RemoteSessionHostStore)?
|
|
private let transientHost: Host
|
|
private let usernameHint: String?
|
|
private var advisoryPassword: SSHSecret?
|
|
private let isExternalTemporaryConnection: Bool
|
|
private var connection: TelnetConnection?
|
|
private var connectionTask: Task<Void, Never>?
|
|
private var startsOnMount = true
|
|
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
|
|
let connectionController = RemoteSessionConnectionController()
|
|
var connectionJourney: ConnectionJourney { connectionController.journey }
|
|
|
|
var agentTemporaryTarget: AgentHostTarget? {
|
|
guard statusStore?.host(hostID) == nil 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"
|
|
}
|
|
let address = transientHost.displayAddress(for: .telnet)
|
|
return AgentHostTarget(
|
|
id: hostID, displayName: title, address: address,
|
|
tags: transientHost.tags, connectionState: state, isTemporary: true,
|
|
preferredTerminalProtocol: .telnet,
|
|
protocols: [.init(name: .telnet, address: address, connectionState: state)])
|
|
}
|
|
|
|
init(
|
|
host: Host, usernameHint: String? = nil,
|
|
advisoryPassword: SSHSecret? = nil,
|
|
isExternalTemporaryConnection: Bool = false
|
|
) {
|
|
hostID = host.id
|
|
title = host.name
|
|
transientHost = host
|
|
self.usernameHint = usernameHint
|
|
self.advisoryPassword = advisoryPassword
|
|
self.isExternalTemporaryConnection = isExternalTemporaryConnection
|
|
let relay = TelnetSessionRelay()
|
|
terminalSurface = PersistentTerminalViewOwner(
|
|
write: { data, _ in
|
|
Task { @MainActor in relay.owner?.connection?.write(data) }
|
|
},
|
|
resize: { viewport in
|
|
Task { @MainActor in
|
|
relay.owner?.latestViewport = viewport
|
|
relay.owner?.connection?.resize(
|
|
columns: UInt16(clamping: viewport.columns),
|
|
rows: UInt16(clamping: viewport.rows))
|
|
}
|
|
}
|
|
)
|
|
relay.owner = self
|
|
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
|
|
connectionJourney.beginTelnet(
|
|
host: host, username: usernameHint, mode: .connection)
|
|
}
|
|
|
|
func copyForReconnect() -> TelnetSessionModel? {
|
|
TelnetSessionModel(
|
|
host: statusStore?.host(hostID) ?? transientHost,
|
|
usernameHint: usernameHint,
|
|
isExternalTemporaryConnection: isExternalTemporaryConnection)
|
|
}
|
|
|
|
func prepareForReopen() {
|
|
startsOnMount = true
|
|
status = .connecting
|
|
}
|
|
|
|
func connectIfNeeded(using store: any RemoteSessionHostStore) {
|
|
guard startsOnMount, status == .connecting else { return }
|
|
connect(using: store)
|
|
}
|
|
|
|
func connect(using store: any RemoteSessionHostStore) {
|
|
startsOnMount = false
|
|
statusStore = store
|
|
connectionTask?.cancel()
|
|
connection?.disconnect()
|
|
connection = nil
|
|
let host = store.host(hostID) ?? transientHost
|
|
guard host.telnetEnabled else {
|
|
fail(String(localized: AppStrings.Connections.telnetIsDisabledForThisHost))
|
|
return
|
|
}
|
|
let username = usernameHint ?? store.identity(for: host, protocol: .telnet)?.username
|
|
connectionJourney.beginTelnet(
|
|
host: host, username: username,
|
|
mode: connectionController.hasConnectedOnce ? .reconnection : .connection)
|
|
status = connectionController.hasConnectedOnce ? .reconnecting : .connecting
|
|
|
|
commandMonitor.beginConnection()
|
|
let terminalSurface = terminalSurface
|
|
let token = UUID()
|
|
let connection = TelnetConnection(
|
|
output: { [weak self] data in
|
|
terminalSurface.receive(data)
|
|
Task { @MainActor in self?.hasUnreadOutput = true }
|
|
},
|
|
event: { [weak self] event in
|
|
Task { @MainActor in
|
|
guard let self, self.connection != nil else { return }
|
|
self.connectionJourney.receive(event)
|
|
if case .connected = event {
|
|
_ = self.connectionController.markConnected()
|
|
self.status = .connected
|
|
self.connectionJourney.succeed()
|
|
}
|
|
}
|
|
},
|
|
closed: { [weak self] message in
|
|
Task { @MainActor in
|
|
guard let self, self.connection != nil else { return }
|
|
self.connection = nil
|
|
self.fail(message ?? String(localized: AppStrings.Terminal.theTelnetConnectionClosed))
|
|
}
|
|
}
|
|
)
|
|
self.connection = connection
|
|
let viewport = latestViewport
|
|
connectionTask = Task { [weak self] in
|
|
do {
|
|
try await store.resolveHostAccess(host, protocol: .telnet)
|
|
var configuration = try store.telnetConnectionConfiguration(for: host)
|
|
if let usernameHint = self?.usernameHint { configuration.username = usernameHint }
|
|
if let password = self?.advisoryPassword {
|
|
configuration.password = password
|
|
self?.advisoryPassword = nil
|
|
}
|
|
try await connection.connect(
|
|
using: configuration,
|
|
columns: UInt16(clamping: viewport.columns),
|
|
rows: UInt16(clamping: viewport.rows))
|
|
} catch is CancellationError {
|
|
await MainActor.run { self?.status = .disconnected }
|
|
} catch {
|
|
await MainActor.run {
|
|
guard self?.connection != nil else { return }
|
|
self?.connection = nil
|
|
self?.fail(error.localizedDescription)
|
|
}
|
|
}
|
|
_ = token
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
startsOnMount = false
|
|
connectionTask?.cancel()
|
|
connectionTask = nil
|
|
connection?.disconnect()
|
|
connection = nil
|
|
status = .disconnected
|
|
connectionController.disconnect()
|
|
advisoryPassword?.clear()
|
|
advisoryPassword = nil
|
|
}
|
|
|
|
func insert(_ text: String, submit: Bool = false) {
|
|
connection?.write(Data((text + (submit ? "\r" : "")).utf8))
|
|
}
|
|
|
|
private func fail(_ message: String) {
|
|
guard status != .disconnected else { return }
|
|
connection?.disconnect()
|
|
connection = nil
|
|
status = .failed(message)
|
|
connectionJourney.fail(message)
|
|
terminalSurface.appendNotice(message)
|
|
}
|
|
|
|
func requestTerminalFocus() { terminalSurface.requestFocus() }
|
|
private func performFind(_ action: String) { terminalSurface.performBindingAction(action) }
|
|
}
|
|
|
|
struct TelnetTerminalView: View {
|
|
let session: TelnetSessionModel
|
|
let isVisible: Bool
|
|
let requestsFocus: Bool
|
|
let closePane: () -> Void
|
|
|
|
@Environment(AppModel.self) private var app
|
|
|
|
var body: some View {
|
|
SimpleDesktopTerminalView(
|
|
state: session.terminalState,
|
|
owner: session.terminalViewOwner,
|
|
journey: session.connectionJourney,
|
|
isVisible: isVisible,
|
|
requestsFocus: requestsFocus,
|
|
connectIfNeeded: { session.connectIfNeeded(using: app.hostLibrary) },
|
|
retry: { session.connect(using: app.hostLibrary) },
|
|
requestFocus: session.requestTerminalFocus,
|
|
closePane: closePane
|
|
)
|
|
}
|
|
|
|
}
|