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

253 lines
9.3 KiB
Swift

import Combine
import GhosttyTerminal
import SwiftUI
import TermindDomain
@MainActor
private final class SerialSessionRelay {
weak var owner: SerialSessionModel?
let inputCapture = TerminalInteractiveInputCapture()
}
nonisolated private final class WeakSerialSessionReference: @unchecked Sendable {
weak var owner: SerialSessionModel?
init(_ owner: SerialSessionModel) { self.owner = owner }
}
/// One temporary serial terminal. Ghostty owns emulation and scrollback; the
/// serial service owns only device discovery and byte transport.
@Observable
final class SerialSessionModel: Identifiable {
let id = UUID()
private(set) var configuration: SerialConnectionConfiguration
var status: ConnectionStatus = .disconnected
var hasUnreadOutput = false
private(set) var findQuery = ""
private(set) var isFindPresented = false
let terminalState: TerminalViewState
#if canImport(AppKit)
let terminalViewOwner: PersistentTerminalViewOwner
#endif
private let terminalBackend: InMemoryTerminalSession
private let service: any SerialPortServing
private var connection: (any SerialPortConnection)?
private var connectionTask: Task<Void, Never>?
let connectionController = RemoteSessionConnectionController()
var connectionJourney: ConnectionJourney { connectionController.journey }
var title: String {
if let name = configuration.deviceName, !name.isEmpty { return name }
return URL(fileURLWithPath: configuration.normalizedDevicePath).lastPathComponent
}
var devicePath: String { configuration.normalizedDevicePath }
var subtitle: String { "\(devicePath) · \(configuration.summary)" }
init(configuration: SerialConnectionConfiguration, service: any SerialPortServing) {
self.configuration = configuration
self.service = service
let relay = SerialSessionRelay()
let backend = InMemoryTerminalSession(
write: { data in
let interactive = relay.inputCapture.classifyWrite()
Task { @MainActor in relay.owner?.handleTerminalWrite(data, interactive: interactive) }
},
resize: { _ in },
suppressesPixelOnlyResizes: true
)
let state = TerminalViewState(configSource: .none)
state.configuration = TerminalSurfaceOptions(
backend: .inMemory(backend), resizeThrottleMilliseconds: 35
)
terminalBackend = backend
terminalState = state
#if canImport(AppKit)
terminalViewOwner = PersistentTerminalViewOwner(
state: state, inputCapture: relay.inputCapture
)
#endif
relay.owner = self
// Present the shared Journey before SwiftUI mounts the pane, preventing
// a one-frame flash of the empty Ghostty surface before `onAppear`
// starts the actual open attempt.
connectionJourney.beginSerial(name: title, address: devicePath)
}
func connect() {
connectionTask?.cancel()
let previousConnection = connection
connection = nil
status = .connecting
connectionJourney.beginSerial(name: title, address: devicePath)
let service = service
let configuration = configuration
let backend = terminalBackend
let reference = WeakSerialSessionReference(self)
connectionTask = Task { [weak self] in
do {
if let previousConnection { await previousConnection.close() }
let opened = try await service.open(
configuration,
receive: { data in
backend.receive(data)
Task { @MainActor in reference.owner?.hasUnreadOutput = true }
},
disconnected: { message in
Task { @MainActor in reference.owner?.fail(message) }
},
progress: { stage in
Task { @MainActor in
reference.owner?.connectionJourney.log(
endpoint: reference.owner?.title ?? configuration.devicePath,
message: stage.message
)
}
}
)
try Task.checkCancellation()
guard let self else {
await opened.close()
return
}
self.connection = opened
self.status = .connected
self.connectionJourney.succeed()
} catch is CancellationError {
self?.status = .disconnected
} catch {
self?.fail(error.localizedDescription)
}
}
}
func disconnect() {
connectionTask?.cancel()
connectionTask = nil
let connection = connection
self.connection = nil
if let connection { Task { await connection.close() } }
status = .disconnected
connectionController.disconnect()
}
private func fail(_ message: String) {
guard status != .disconnected else { return }
let connection = connection
self.connection = nil
if let connection { Task { await connection.close() } }
status = .failed(message)
connectionJourney.fail(message)
terminalBackend.receive(
Data("\r\n\u{001B}[2m[Termind] \(message)\u{001B}[0m\r\n".utf8)
)
}
func handleTerminalWrite(_ data: Data, interactive: Bool) {
guard let connection else { return }
let output = interactive ? configuration.transformInteractiveInput(data) : data
if interactive, configuration.localEcho {
let echo = Data(data.flatMap { $0 == 0x0D ? [0x0D, 0x0A] : [$0] })
terminalBackend.receive(echo)
}
Task {
do { try await connection.write(output) } catch {
await MainActor.run { self.fail(error.localizedDescription) }
}
}
}
func requestTerminalFocus() {
#if canImport(AppKit)
terminalViewOwner.requestFocus()
#else
terminalState.requestFocus()
#endif
}
func presentFind() {
let wasPresented = isFindPresented
isFindPresented = true
if !wasPresented, !findQuery.isEmpty { performFind(TerminalFindBinding.search(findQuery)) }
}
func updateFindQuery(_ query: String) {
guard query != findQuery else { return }
findQuery = query
performFind(TerminalFindBinding.search(query))
}
func presentFindAndNavigate(_ direction: TerminalFindDirection) {
presentFind()
guard !findQuery.isEmpty else { return }
performFind(TerminalFindBinding.navigate(direction))
}
func dismissFind() {
guard isFindPresented else { return }
performFind(TerminalFindBinding.end)
isFindPresented = false
requestTerminalFocus()
}
private func performFind(_ action: String) {
#if canImport(AppKit)
terminalViewOwner.performBindingAction(action)
#endif
}
}
struct SerialTerminalView: View {
let session: SerialSessionModel
let isVisible: Bool
let requestsFocus: Bool
let closePane: () -> Void
@TerminalAppearanceSettings private var terminalAppearance
@FocusState private var focused: Bool
var body: some View {
PersistentTerminalSurfaceView(
state: session.terminalState,
owner: session.terminalViewOwner,
isVisible: isVisible,
requestsFocus: requestsFocus && !session.connectionJourney.isPresented
)
.background(terminalAppearance.background)
.overlay { connectionJourney }
.focused($focused)
.onAppear {
session.terminalState.isSurfaceVisible = isVisible
session.terminalState.applyAppearance(terminalAppearance.preferences)
if session.status == .connecting { session.connect() }
if requestsFocus { session.requestTerminalFocus() }
}
.onChange(of: isVisible) { _, value in session.terminalState.isSurfaceVisible = value }
.onChange(of: requestsFocus) { _, value in
focused = value
if value, !session.connectionJourney.isPresented { session.requestTerminalFocus() }
}
.onChange(of: session.connectionJourney.isPresented) { _, presented in
if !presented, requestsFocus { session.requestTerminalFocus() }
}
.onChange(of: terminalAppearance.preferences) { _, value in
session.terminalState.applyAppearance(value)
}
.onDisappear { session.terminalState.isSurfaceVisible = false }
}
@ViewBuilder
private var connectionJourney: some View {
if isVisible, session.connectionJourney.isPresented {
ConnectionJourneyView(
journey: session.connectionJourney,
submitAuthentication: { _, _, _, _, _, _ in },
continueHostKey: { _ in },
trustHostKey: { _ in },
denyHostKey: closePane,
retry: session.connect,
cancel: closePane,
failureCancelLabel: "Close",
failureRetryLabel: "Reconnect",
compactFailureActions: true
)
}
}
}