feat: add serial terminal sessions

This commit is contained in:
Kaede Akino committed 2026-08-29 13:09:28 +08:00
1 parent 74292a9135
commit 9e4175485f
21 files changed
+1875 -78

No files matched your search

+6 -2
View File
@@ -41,9 +41,9 @@ framework and concrete-client restrictions that directory layout alone cannot.
- Assistant owns its conversation list through `AssistantLibraryModel`; conversations are
not panes and do not participate in Workspace drag, split, close, or reconnect
lifecycles.
- A workspace owns only SSH/SFTP panes plus its session-scoped assistant
- A workspace owns SSH/SFTP/serial panes plus its session-scoped assistant
conversation. Workspace content is full width and has no left sidebar.
- Session models own live SSH/SFTP resources and terminal/file-browser state.
- Session models own live SSH/SFTP/serial resources and terminal/file-browser state.
- Settings is a native singleton macOS `Settings` scene, outside window routing.
Repository data is persistent truth. Runtime connection state is kept in a
@@ -61,6 +61,10 @@ keep their backing aggregate private, and Terminal/SFTP sessions depend on the
narrow `RemoteSessionHostStore` port rather than the complete Vault mutation
surface.
Serial sessions are temporary and local: IOKit/termios discovery and descriptors
stay in Infrastructure, parameters live only with the open pane, and no serial
path is persisted or exposed as an Agent host.
Window navigation has one Navigator plus three tool sidebars: Assistant,
Snippets, and History. Dynamic SSH/SFTP containers are consistently named
Workspaces; “tab” is reserved for keyboard or native UI tab semantics. Vault
+3
View File
@@ -5,18 +5,21 @@ struct AppDependencies {
let hostRepository: any HostRepository
let outboxRunner: OutboxRunner?
let connectionCoordinator: RemoteConnectionCoordinator
let serialPortService: any SerialPortServing
let agentRunRepository: (any AgentRunPersisting)?
let vaultSync: VaultSyncCoordinator?
init(
hostRepository: any HostRepository, outboxRunner: OutboxRunner?,
connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator(),
serialPortService: any SerialPortServing = SystemSerialPortService(),
agentRunRepository: (any AgentRunPersisting)? = nil,
vaultSync: VaultSyncCoordinator? = nil
) {
self.hostRepository = hostRepository
self.outboxRunner = outboxRunner
self.connectionCoordinator = connectionCoordinator
self.serialPortService = serialPortService
self.agentRunRepository = agentRunRepository
self.vaultSync = vaultSync
}
+2
View File
@@ -62,6 +62,7 @@ final class AppModel {
let mcpServer: MCPServerController
let commands = AppCommandRegistry()
let connections: RemoteConnectionCoordinator
let serialPorts: any SerialPortServing
let hostRepository: any HostRepository
let vaultSync: VaultSyncCoordinator?
let forwarding: PortForwardingController
@@ -121,6 +122,7 @@ final class AppModel {
self.externalToolCalls = externalToolCalls
externalAuthentication = ExternalAuthCoordinator()
connections = connectionCoordinator
serialPorts = dependencies.serialPortService
agentRunRepository = dependencies.agentRunRepository ?? InMemoryAgentRunRepository()
agentHosts = AgentHostResolver(
store: hosts,
@@ -3,6 +3,7 @@ import Foundation
nonisolated enum SSHConnectionPurpose: String, Hashable, Sendable {
case sshSession
case sftpSession
case serialSession
}
nonisolated struct SSHConnectionEndpoint: Identifiable, Hashable, Sendable {
@@ -15,6 +16,7 @@ nonisolated struct SSHConnectionEndpoint: Identifiable, Hashable, Sendable {
nonisolated enum ID: Hashable, Sendable {
case local
case host(Host.ID)
case serial(String)
}
var id: ID
@@ -143,15 +145,43 @@ final class SSHConnectionJourney {
let action = mode == .reconnection ? "Reconnecting " : "Starting "
log(
endpoint: endpoints.first?.name ?? String(localized: "This Mac"),
message: action
+ (purpose == .sshSession ? "SSH session" : "SFTP session")
+ " connection to " + host.name
message: action + purpose.logName + " connection to " + host.name
)
if let username, !username.isEmpty {
log(endpoint: host.name, message: "Using login " + username)
}
}
func beginSerial(name: String, address: String, mode: Mode = .connection) {
completionTask?.cancel()
purpose = .serialSession
self.mode = mode
endpoints = [
SSHConnectionEndpoint(
id: .local, hostID: nil,
name: String(localized: "This Mac"), address: "localhost", role: .local
),
SSHConnectionEndpoint(
id: .serial(address), hostID: nil,
name: name, address: address, role: .destination
),
]
phase = .connecting
activeEndpointIndex = 1
completedEndpointIndex = 0
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
logs = []
showsLogs = false
log(
endpoint: endpoints[0].name,
message: (mode == .reconnection ? "Reconnecting " : "Starting ")
+ "serial connection to " + name
)
log(endpoint: name, message: "Opening " + address)
}
func receive(_ event: SSHRouteEvent) {
let hostID: Host.ID
let message: String
@@ -300,3 +330,13 @@ final class SSHConnectionJourney {
log(endpoint: endpoints[index].name, message: message)
}
}
private extension SSHConnectionPurpose {
var logName: String {
switch self {
case .sshSession: "SSH session"
case .sftpSession: "SFTP session"
case .serialSession: "serial session"
}
}
}
@@ -0,0 +1,48 @@
import Foundation
nonisolated enum SerialPortError: LocalizedError, Sendable {
case invalidConfiguration(String)
case system(operation: String, code: Int32)
case closed
case openTimedOut
var errorDescription: String? {
switch self {
case .invalidConfiguration(let message): message
case .system(let operation, let code):
"\(operation): \(String(cString: strerror(code)))"
case .closed: String(localized: "The serial port is closed.")
case .openTimedOut: String(localized: "Opening the serial port timed out.")
}
}
}
nonisolated enum SerialPortOpenStage: Sendable {
case deviceOpened
case settingsApplied
case monitoringStarted
var message: String {
switch self {
case .deviceOpened: String(localized: "Serial device opened")
case .settingsApplied: String(localized: "Serial settings applied")
case .monitoringStarted: String(localized: "Serial input monitoring started")
}
}
}
protocol SerialPortConnection: Sendable {
func write(_ data: Data) async throws
func close() async
}
protocol SerialPortServing: Sendable {
func availablePorts() async -> [SerialPortDescriptor]
func portUpdates() -> AsyncStream<[SerialPortDescriptor]>
func open(
_ configuration: SerialConnectionConfiguration,
receive: @escaping @Sendable (Data) -> Void,
disconnected: @escaping @Sendable (String) -> Void,
progress: @escaping @Sendable (SerialPortOpenStage) -> Void
) async throws -> any SerialPortConnection
}
@@ -0,0 +1,106 @@
import Foundation
nonisolated struct SerialPortDescriptor: Identifiable, Hashable, Sendable {
var path: String
var name: String?
var id: String { path }
var displayName: String {
guard let name, !name.isEmpty, name != path else { return path }
return "\(name) — \(path)"
}
}
nonisolated enum SerialDataBits: Int, CaseIterable, Identifiable, Sendable {
case five = 5, six = 6, seven = 7, eight = 8
var id: Int { rawValue }
}
nonisolated enum SerialParity: String, CaseIterable, Identifiable, Sendable {
case none, even, odd
var id: String { rawValue }
var displayName: String {
switch self {
case .none: String(localized: "None")
case .even: String(localized: "Even")
case .odd: String(localized: "Odd")
}
}
}
nonisolated enum SerialStopBits: Int, CaseIterable, Identifiable, Sendable {
case one = 1, two = 2
var id: Int { rawValue }
}
nonisolated enum SerialFlowControl: String, CaseIterable, Identifiable, Sendable {
case none, software, hardware
var id: String { rawValue }
var displayName: String {
switch self {
case .none: String(localized: "None")
case .software: String(localized: "XON/XOFF")
case .hardware: String(localized: "RTS/CTS")
}
}
}
nonisolated enum SerialEnterKey: String, CaseIterable, Identifiable, Sendable {
case carriageReturn, lineFeed, carriageReturnLineFeed
var id: String { rawValue }
var displayName: String {
switch self {
case .carriageReturn: String(localized: "CR")
case .lineFeed: String(localized: "LF")
case .carriageReturnLineFeed: String(localized: "CRLF")
}
}
var bytes: [UInt8] {
switch self {
case .carriageReturn: [0x0D]
case .lineFeed: [0x0A]
case .carriageReturnLineFeed: [0x0D, 0x0A]
}
}
}
nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
static let commonBaudRates = [
300, 600, 1_200, 2_400, 4_800, 9_600, 19_200, 38_400, 57_600,
115_200, 230_400, 460_800, 921_600, 1_000_000, 2_000_000,
3_000_000, 4_000_000,
]
var devicePath = ""
var deviceName: String?
var baudRate = 115_200
var dataBits: SerialDataBits = .eight
var parity: SerialParity = .none
var stopBits: SerialStopBits = .one
var flowControl: SerialFlowControl = .none
var enterKey: SerialEnterKey = .carriageReturn
var localEcho = false
var normalizedDevicePath: String {
devicePath.trimmingCharacters(in: .whitespacesAndNewlines)
}
var isValid: Bool {
normalizedDevicePath.hasPrefix("/") && (1...4_000_000).contains(baudRate)
}
var summary: String {
"\(baudRate) \(dataBits.rawValue)-\(parityLetter)-\(stopBits.rawValue)"
}
private var parityLetter: String {
switch parity { case .none: "N"; case .even: "E"; case .odd: "O" }
}
func transformInteractiveInput(_ data: Data) -> Data {
guard enterKey != .carriageReturn, data.contains(0x0D) else { return data }
var result = Data()
for byte in data {
if byte == 0x0D { result.append(contentsOf: enterKey.bytes) }
else { result.append(byte) }
}
return result
}
}
@@ -1,4 +1,5 @@
import AppKit
import Darwin
import ListViewKit
import SwiftUI
@@ -26,13 +27,23 @@ nonisolated struct SnippetHostChooserRequest: Hashable, Sendable {
nonisolated enum HostConnectionProtocol: String, CaseIterable, Identifiable, Sendable {
case ssh = "SSH"
case sftp = "SFTP"
case serial = "Serial"
nonisolated var id: Self { self }
var target: HostOpenTarget {
var displayName: String {
switch self {
case .ssh: String(localized: "SSH")
case .sftp: String(localized: "SFTP")
case .serial: String(localized: "Serial")
}
}
var target: HostOpenTarget? {
switch self {
case .ssh: .sshSession
case .sftp: .sftpSession
case .serial: nil
}
}
}
@@ -150,6 +161,10 @@ struct HostChooserView: View {
@Environment(\.dismiss) private var dismiss
@State private var chooser: ManagedChooserState<Host.ID>
@State private var connectionProtocol = HostConnectionProtocol.ssh
@State private var serialConfiguration = SerialConnectionConfiguration()
@State private var serialBaudRate = "115200"
@State private var serialPorts: [SerialPortDescriptor] = []
@State private var serialError: String?
@State private var isConfirmingDestructiveSnippet = false
init(purpose: HostChooserPurpose, presentation: Presentation = .sheet) {
@@ -195,10 +210,18 @@ struct HostChooserView: View {
width: sheetWidth,
height: presentation == .sheet ? Metrics.HostPicker.height : nil
)
.animation(Motion.panel, value: connectionProtocol)
.onChange(of: chooser.query) {
let ids = results.map(\.id)
chooser.queryDidChange(visible: ids, enabled: Set(ids))
}
.task(id: connectionProtocol) {
guard connectionProtocol == .serial else { return }
for await ports in app.serialPorts.portUpdates() {
guard !Task.isCancelled else { return }
serialPorts = ports
}
}
.confirmationDialog(
"Execute a destructive script?",
isPresented: $isConfirmingDestructiveSnippet,
@@ -227,6 +250,7 @@ struct HostChooserView: View {
query: queryBinding,
aboveSearch: purpose.choosesConnectionProtocol
? AnyView(connectionProtocolPicker) : nil,
showsSearch: connectionProtocol != .serial,
onSubmit: openSelected,
moveCursor: move,
toggleCursor: toggleCursor
@@ -235,8 +259,8 @@ struct HostChooserView: View {
private var connectionProtocolPicker: some View {
Picker("Connection Protocol", selection: $connectionProtocol) {
ForEach(HostConnectionProtocol.allCases) { connectionProtocol in
Text(connectionProtocol.rawValue).tag(connectionProtocol)
ForEach(availableConnectionProtocols) { connectionProtocol in
Text(connectionProtocol.displayName).tag(connectionProtocol)
}
}
.pickerStyle(.segmented)
@@ -245,9 +269,15 @@ struct HostChooserView: View {
.accessibilityLabel("Connection protocol")
}
private var availableConnectionProtocols: [HostConnectionProtocol] {
purpose == .connect ? HostConnectionProtocol.allCases : [.ssh, .sftp]
}
@ViewBuilder
private var content: some View {
if app.hostLibrary.hosts.isEmpty {
if connectionProtocol == .serial {
serialForm
} else if app.hostLibrary.hosts.isEmpty {
ContentUnavailableView {
Label("No Hosts", systemImage: "server.rack")
} description: {
@@ -281,6 +311,71 @@ struct HostChooserView: View {
}
}
private var serialForm: some View {
Form {
Section("Device") {
SerialEditableComboField(
title: "Device Path",
value: $serialConfiguration.devicePath,
options: serialPorts.map {
SerialComboOption(value: $0.path, label: $0.displayName)
}
)
if serialPorts.isEmpty {
Text("No detected serial devices. You can still enter any absolute device path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Communication") {
SerialEditableComboField(
title: "Baud Rate",
value: $serialBaudRate,
options: SerialConnectionConfiguration.commonBaudRates.map {
SerialComboOption(value: String($0), label: $0.formatted())
}
)
Picker("Data Bits", selection: $serialConfiguration.dataBits) {
ForEach(SerialDataBits.allCases) { Text("\($0.rawValue)").tag($0) }
}
Picker("Parity", selection: $serialConfiguration.parity) {
ForEach(SerialParity.allCases) { Text($0.displayName).tag($0) }
}
Picker("Stop Bits", selection: $serialConfiguration.stopBits) {
ForEach(SerialStopBits.allCases) { Text("\($0.rawValue)").tag($0) }
}
Picker("Flow Control", selection: $serialConfiguration.flowControl) {
ForEach(SerialFlowControl.allCases) { Text($0.displayName).tag($0) }
}
}
Section("Terminal Input") {
Picker("Enter Sends", selection: $serialConfiguration.enterKey) {
ForEach(SerialEnterKey.allCases) { Text($0.displayName).tag($0) }
}
Toggle("Local Echo", isOn: $serialConfiguration.localEcho)
}
if let serialError {
Text(serialError).foregroundStyle(.red).font(.caption)
}
}
.formStyle(.grouped)
.onChange(of: serialConfiguration.devicePath) {
serialConfiguration.deviceName = serialPorts.first {
$0.path == serialConfiguration.devicePath
}?.name
serialError = nil
}
.onChange(of: serialBaudRate) {
if let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) {
serialConfiguration.baudRate = rate
serialError = nil
}
}
}
private var hostListItems: [HostChooserListItem] {
results.map { host in
HostChooserListItem(
@@ -294,7 +389,7 @@ struct HostChooserView: View {
private var footer: some View {
HStack {
if purpose.allowsMultiple {
if purpose.allowsMultiple, connectionProtocol != .serial {
Text("\(chooser.selected.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
@@ -319,11 +414,21 @@ struct HostChooserView: View {
Button(purpose.confirmLabel, action: openSelected)
.keyboardShortcut(.defaultAction)
.disabled(purpose.allowsMultiple ? chooser.selected.isEmpty : selectedHost == nil)
.disabled(connectIsDisabled)
}
.padding(Metrics.Space.l)
}
private var connectIsDisabled: Bool {
if connectionProtocol == .serial {
guard let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) else {
return true
}
return !serialConfiguration.isValid
}
return purpose.allowsMultiple ? chooser.selected.isEmpty : selectedHost == nil
}
// MARK: - Checking
private var allResultsChecked: Bool {
@@ -342,20 +447,42 @@ struct HostChooserView: View {
}
private func openSelected() {
if purpose.allowsMultiple {
if connectionProtocol == .serial {
openSerial()
} else if purpose.allowsMultiple {
openChecked()
} else if let host = selectedHost {
open(host)
}
}
private func openSerial() {
guard serialConfiguration.isValid else { return }
guard let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) else { return }
serialConfiguration.baudRate = rate
let path = serialConfiguration.normalizedDevicePath
var info = stat()
guard lstat(path, &info) == 0 else {
serialError = String(localized: "The device path does not exist.")
return
}
guard (info.st_mode & S_IFMT) == S_IFCHR else {
serialError = String(localized: "The device path is not a character device.")
return
}
serialConfiguration.devicePath = path
window.openSerialSession(configuration: serialConfiguration, service: app.serialPorts)
dismiss()
}
private func openChecked(as layout: BatchLayout = .separateWorkspaces) {
let batch = app.hostLibrary.searchHosts("").filter { chooser.selected.contains($0.id) }
guard !batch.isEmpty else { return }
if case .snippet = purpose {
requestSnippetExecution()
} else {
app.open(batch, as: connectionProtocol.target, layout: layout, in: window)
guard let target = connectionProtocol.target else { return }
app.open(batch, as: target, layout: layout, in: window)
dismiss()
}
}
@@ -438,7 +565,8 @@ struct HostChooserView: View {
}
private func open(_ host: Host) {
app.open(host, as: connectionProtocol.target, in: window)
guard let target = connectionProtocol.target else { return }
app.open(host, as: target, in: window)
dismiss()
}
}
@@ -56,6 +56,7 @@ struct StatusBarView: View {
}
private var sftpSession: SFTPSessionModel? { pane?.sftpSession }
private var serialSession: SerialSessionModel? { pane?.serialSession }
/// One rule decides both whether the capsule appears at all and what it
/// shrinks to, so the two can never disagree — see `StatusBarPresentation`.
@@ -203,6 +204,7 @@ struct StatusBarView: View {
private var status: ConnectionStatus {
sshSession?.status
?? sftpSession?.status
?? serialSession?.status
?? pane?.hostID.map { app.hostLibrary.status(of: $0) }
?? .disconnected
}
@@ -220,11 +222,17 @@ struct StatusBarView: View {
if let host {
Text(host.name)
Text(host.host).foregroundStyle(.tertiary)
} else if let serialSession {
Text(serialSession.title)
Text(serialSession.subtitle).foregroundStyle(.tertiary)
} else {
Text(status.label)
}
}
.help(Text(host.map { app.hostLibrary.loginAddress(for: $0) } ?? status.label))
.help(Text(
host.map { app.hostLibrary.loginAddress(for: $0) }
?? serialSession?.subtitle ?? status.label
))
}
private var host: Host? { pane?.hostID.flatMap { app.hostLibrary.host($0) } }
@@ -0,0 +1,121 @@
import SwiftUI
nonisolated struct SerialComboOption: Identifiable, Hashable, Sendable {
let value: String
let label: String
var id: String { value }
}
/// An editable value and its known presets in one control. Serial paths and
/// baud rates both need this shape: discovery is helpful, but must never make a
/// driver-specific value impossible to type.
struct SerialEditableComboField: View {
let title: LocalizedStringKey
@Binding var value: String
let options: [SerialComboOption]
var monospaced = true
@State private var presentsOptions = false
var body: some View {
LabeledContent {
HStack(spacing: 0) {
TextField("", text: $value)
.textFieldStyle(.plain)
.font(monospaced ? .body.monospaced() : .body)
.padding(.leading, Metrics.Space.m)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityLabel(title)
Rectangle()
.fill(.quaternary.opacity(0.75))
.frame(width: 1, height: 14)
Button {
presentsOptions.toggle()
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 28, height: 22)
.contentShape(.rect)
}
.buttonStyle(.plain)
.fixedSize()
// Arrow on the popover's top edge keeps the option list below
// the editable field instead of letting NSMenu align it beside
// the current value.
.popover(
isPresented: $presentsOptions,
attachmentAnchor: .rect(.bounds),
arrowEdge: .top
) {
optionList
.presentationCompactAdaptation(.popover)
}
}
.frame(minWidth: 260, idealWidth: 320, maxWidth: 360, minHeight: 22, maxHeight: 22)
.background(
.quaternary.opacity(0.22),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
.overlay {
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
.stroke(.quaternary.opacity(0.55), lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .contain)
// LabeledContent aligns its two columns on firstTextBaseline. Make
// that baseline the visual centre of the custom control so it
// agrees with native Picker rows without replacing LabeledContent.
.alignmentGuide(.firstTextBaseline) { dimensions in
dimensions[VerticalAlignment.center]
}
} label: {
Text(title)
.frame(height: 22, alignment: .center)
.alignmentGuide(.firstTextBaseline) { dimensions in
dimensions[VerticalAlignment.center]
}
}
}
private var optionList: some View {
ScrollView {
LazyVStack(spacing: Metrics.Space.xxs) {
if options.isEmpty {
Text("No detected serial devices")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Metrics.Space.m)
} else {
ForEach(options) { option in
Button {
value = option.value
presentsOptions = false
} label: {
HStack(spacing: Metrics.Space.m) {
Image(systemName: "checkmark")
.frame(width: 12)
.opacity(option.value == value ? 1 : 0)
Text(option.label)
.font(monospaced ? .body.monospaced() : .body)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.padding(.horizontal, Metrics.Space.m)
.frame(height: 30)
.contentShape(.rect)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
}
}
}
.padding(Metrics.Space.xs)
}
.frame(width: 360)
.frame(minHeight: 38, maxHeight: 260)
}
}
@@ -0,0 +1,267 @@
import Combine
import GhosttyTerminal
import SwiftUI
@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: SSHConnectionJourney { 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
}
func applyAppearance(_ preferences: TerminalAppearancePreferences) {
let light = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: preferences.lightThemeName,
customEnabled: preferences.lightCustomEnabled,
customText: preferences.lightCustomConfiguration,
isDark: false
)
let dark = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: preferences.darkThemeName,
customEnabled: preferences.darkCustomEnabled,
customText: preferences.darkCustomConfiguration,
isDark: true
)
terminalState.setTheme(GhosttyTerminal.TerminalTheme(light: light, dark: dark))
terminalState.setTerminalConfiguration(
TerminalConfiguration { builder in
if !preferences.fontFollowTheme { builder.withFontSize(Float(preferences.fontSize)) }
builder.withWindowPaddingX(Int(Metrics.Space.m))
builder.withWindowPaddingY(Int(Metrics.Space.m))
}
)
}
}
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.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.applyAppearance(value) }
.onDisappear { session.terminalState.isSurfaceVisible = false }
}
@ViewBuilder
private var connectionJourney: some View {
if isVisible, session.connectionJourney.isPresented {
SSHConnectionJourneyView(
journey: session.connectionJourney,
submitAuthentication: { _, _, _, _, _, _ in },
continueHostKey: { _ in },
trustHostKey: { _ in },
denyHostKey: closePane,
retry: session.connect,
cancel: closePane,
failureCancelLabel: "Close",
failureRetryLabel: "Reconnect",
compactFailureActions: true
)
}
}
}
+45 -8
View File
@@ -13,6 +13,7 @@ enum PaneContent {
// because a transfer is a conversation with one machine about one path.
case sshSession(SSHSessionModel)
case sftpSession(SFTPSessionModel)
case serialSession(SerialSessionModel)
/// A slot that has been carved out and not filled yet.
///
/// Split makes one of these rather than opening a connection, because
@@ -36,6 +37,8 @@ nonisolated enum PaneKind: Hashable, Sendable {
/// One host's SFTP browser. It shares the Sessions section with SSH while
/// retaining its own kind for protocol-specific actions and iconography.
case sftpSession
/// A temporary terminal attached directly to a local serial device.
case serialSession
/// An unfilled split. Belongs in no sidebar section — it is a hole in a
/// layout, and listing it would put a row in Sessions for something that is
/// not a session yet and may never be one.
@@ -55,7 +58,7 @@ nonisolated enum PaneKind: Hashable, Sendable {
/// `.empty` is not combinable, which is what stops a half-arranged Workspace
/// from being dragged into another one before it has been filled.
var isCombinable: Bool {
self == .sshSession || self == .sftpSession
self == .sshSession || self == .sftpSession || self == .serialSession
}
/// Whether a pane of this kind may be given a name.
@@ -70,14 +73,14 @@ nonisolated enum PaneKind: Hashable, Sendable {
/// and an unfilled slot are the same story: both are named after the state
/// they are in, and both stop existing when that state ends.
var isRenameable: Bool {
self == .sshSession || self == .sftpSession
self == .sshSession || self == .sftpSession || self == .serialSession
}
/// Only work panes have transient context worth reserving a footer for.
/// Manager pages, forms and empty slots should use the full content height
/// instead of permanently reporting the fallback "Not connected" state.
var showsStatusBar: Bool {
self == .sshSession || self == .sftpSession
self == .sshSession || self == .sftpSession || self == .serialSession
}
}
@@ -113,6 +116,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession(let session): session.connectionCoordinator = coordinator
case .sftpSession(let session): session.connectionCoordinator = coordinator
case .serialSession: break
case .empty: break
}
}
@@ -131,6 +135,7 @@ final class Pane: Identifiable {
switch content {
case .sftpSession: .sftpSession
case .sshSession: .sshSession
case .serialSession: .serialSession
case .empty: .empty
}
}
@@ -149,6 +154,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession: "apple.terminal"
case .sftpSession: "folder"
case .serialSession: "cable.connector"
case .empty: "rectangle.dashed"
}
}
@@ -165,6 +171,11 @@ final class Pane: Identifiable {
return nil
}
var serialSession: SerialSessionModel? {
if case .serialSession(let serialSession) = content { return serialSession }
return nil
}
/// Ends network state only when the pane is actually being closed. View
/// disappearance is not ownership: dragging a pane reparents its view and
/// must leave the same live session connected.
@@ -172,6 +183,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession(let sshSession): sshSession.disconnect()
case .sftpSession(let sftpSession): sftpSession.disconnect()
case .serialSession(let serialSession): serialSession.disconnect()
default: break
}
}
@@ -181,17 +193,24 @@ final class Pane: Identifiable {
/// `disconnectConnection`. Keep the Pane itself so its name, duplicate
/// index, focus identity, and place in the split tree all survive.
func prepareForReopen() {
guard case .sshSession(let sshSession) = content else { return }
sshSession.inputRouter = nil
let replacement = sshSession.copyForReconnect()
replacement.status = .connecting
content = .sshSession(replacement)
switch content {
case .sshSession(let sshSession):
sshSession.inputRouter = nil
let replacement = sshSession.copyForReconnect()
replacement.status = .connecting
content = .sshSession(replacement)
case .serialSession(let serialSession):
serialSession.status = .connecting
case .sftpSession, .empty:
break
}
}
var status: ConnectionStatus {
switch content {
case .sshSession(let sshSession): sshSession.status
case .sftpSession(let sftpSession): sftpSession.status
case .serialSession(let serialSession): serialSession.status
default: .disconnected
}
}
@@ -199,6 +218,7 @@ final class Pane: Identifiable {
var hasUnreadOutput: Bool {
switch content {
case .sshSession(let sshSession): sshSession.hasUnreadOutput
case .serialSession(let serialSession): serialSession.hasUnreadOutput
default: false
}
}
@@ -209,6 +229,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession(let sshSession): sshSession.hostID
case .sftpSession(let sftpSession): sftpSession.hostID
case .serialSession: nil
case .empty: nil
}
}
@@ -217,6 +238,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession(let sshSession): sshSession.workingDirectory
case .sftpSession(let sftpSession): sftpSession.path
case .serialSession: nil
default: nil
}
}
@@ -225,6 +247,7 @@ final class Pane: Identifiable {
switch content {
case .sshSession(let sshSession): sshSession.displayedWorkingDirectory
case .sftpSession(let sftpSession): sftpSession.displayedPath
case .serialSession: nil
default: nil
}
}
@@ -255,6 +278,8 @@ final class Pane: Identifiable {
return Pane(.sshSession(clone))
case .sftpSession(let sftpSession):
return Pane(.sftpSession(sftpSession.copyForBrowsing()))
case .serialSession:
return nil
case .empty:
return Pane(.empty)
}
@@ -280,6 +305,8 @@ final class Pane: Identifiable {
sshSession.title
case .sftpSession(let sftpSession):
store.host(sftpSession.hostID)?.name ?? String(localized: "SFTP")
case .serialSession(let serialSession):
serialSession.title
case .empty:
String(localized: "Empty")
}
@@ -303,6 +330,8 @@ final class Pane: Identifiable {
switch content {
case .sftpSession(let sftpSession):
sftpSession.displayedPath
case .serialSession(let serialSession):
serialSession.subtitle
case _ where customTitle != nil:
// A renamed pane shows its name and nothing under it: the subtitle
// is the derived detail, and pairing it with a name the user chose
@@ -354,6 +383,14 @@ extension Pane {
hostID: host.id, initialPath: initialPath ?? host.sftpSessionInitialPath
)))
}
static func serialSession(
configuration: SerialConnectionConfiguration, service: any SerialPortServing
) -> Pane {
let session = SerialSessionModel(configuration: configuration, service: service)
session.status = .connecting
return Pane(.serialSession(session))
}
}
nonisolated enum SplitAxis: Hashable, Sendable {
+28 -5
View File
@@ -224,7 +224,11 @@ final class WindowModel {
selectedWorkspace?.focusedPane.sftpSession
}
var canFindInTerminal: Bool { focusedSSHSession != nil }
var focusedSerialSession: SerialSessionModel? {
selectedWorkspace?.focusedPane.serialSession
}
var canFindInTerminal: Bool { focusedSSHSession != nil || focusedSerialSession != nil }
var canGenerateTerminalCommand: Bool { focusedSSHSession != nil }
@@ -274,7 +278,7 @@ final class WindowModel {
case .assistant:
return .assistant
case .workspace:
if focusedSSHSession != nil { return .terminal }
if focusedSSHSession != nil || focusedSerialSession != nil { return .terminal }
if focusedSFTPSession != nil { return .sftp }
return nil
case .vault:
@@ -310,7 +314,10 @@ final class WindowModel {
}
guard findKind != nil else { return }
isToolbarFindFocused = true
if findKind == .terminal { focusedSSHSession?.presentFind() }
if findKind == .terminal {
focusedSSHSession?.presentFind()
focusedSerialSession?.presentFind()
}
findFocusRequest &+= 1
// On the next turn, so the update the two lines above set off has
// already landed and cannot take the caret straight back out.
@@ -325,6 +332,7 @@ final class WindowModel {
/// the two used to disagree.
func navigateTerminalFind(_ direction: TerminalFindDirection) {
focusedSSHSession?.presentFindAndNavigate(direction)
focusedSerialSession?.presentFindAndNavigate(direction)
}
/// The workspace whose content the detail column shows.
@@ -386,10 +394,14 @@ final class WindowModel {
/// them: it is a page, so its `kind` is `.page`, and it lives in Manage.
var sftpSessionPanes: [PaneRef] { panes(ofKind: .sftpSession) }
var serialSessionPanes: [PaneRef] { panes(ofKind: .serialSession) }
var sessionPanes: [PaneRef] {
workspaces.flatMap { workspace in
workspace.panes.compactMap { pane in
guard pane.kind == .sshSession || pane.kind == .sftpSession else { return nil }
guard pane.kind == .sshSession || pane.kind == .sftpSession
|| pane.kind == .serialSession
else { return nil }
return PaneRef(workspace: workspace, pane: pane)
}
}
@@ -741,6 +753,14 @@ final class WindowModel {
return open(Workspace(pane: pane))
}
@discardableResult
func openSerialSession(
configuration: SerialConnectionConfiguration,
service: any SerialPortServing
) -> Workspace {
open(Workspace(pane: .serialSession(configuration: configuration, service: service)))
}
/// A one-shot directory change for a shell opened from an SFTP path.
/// Single-quoting keeps spaces and shell punctuation inert; embedded
/// apostrophes are closed, emitted literally, and reopened.
@@ -958,7 +978,10 @@ final class WindowModel {
selectedWorkspace?.focusedPane.isCombinable ?? false
}
var canDuplicateSelected: Bool { canSplitSelected }
var canDuplicateSelected: Bool {
guard canSplitSelected, let workspace = selectedWorkspace else { return false }
return !workspace.panes.contains { $0.serialSession != nil }
}
/// Carves an empty slot beside the focused pane, for something already open
/// to be dragged into. Deliberately not a connection: choosing a layout and
@@ -205,6 +205,7 @@ final class Workspace: Identifiable {
func markRead() {
for pane in panes {
pane.sshSession?.hasUnreadOutput = false
pane.serialSession?.hasUnreadOutput = false
}
}
@@ -503,7 +503,7 @@ private struct PaneView: View {
return true
}
)
case .empty:
case .serialSession, .empty:
EmptyView()
}
}
@@ -582,6 +582,13 @@ private struct PaneView: View {
isExternalFileDropTargeted: isLocalFileDropTargeted,
closePane: { window.closePane(pane.id) }
)
case .serialSession(let serialSession):
SerialTerminalView(
session: serialSession,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused,
closePane: { window.closePane(pane.id) }
)
case .empty:
EmptyPaneView()
}
@@ -606,7 +613,7 @@ private struct PaneView: View {
headerContent.background {
SSHConnectionJourneyBackground()
}
} else if pane.kind == .sshSession {
} else if pane.kind == .sshSession || pane.kind == .serialSession {
headerContent.background(terminalAppearance.background)
} else if pane.kind == .sftpSession {
headerContent.background(.background)
@@ -621,6 +628,8 @@ private struct PaneView: View {
session.connectionJourney.isPresented
case .sftpSession(let session):
session.connectionJourney.isPresented
case .serialSession(let session):
session.connectionJourney.isPresented
case .empty:
false
}
@@ -0,0 +1,369 @@
import Darwin
import Foundation
import IOKit
import IOKit.serial
nonisolated final class SystemSerialPortService: SerialPortServing, @unchecked Sendable {
private let openQueue = DispatchQueue(
label: "com.akinokaede.termind.serial.open", attributes: .concurrent
)
func availablePorts() async -> [SerialPortDescriptor] { Self.scanPorts() }
func portUpdates() -> AsyncStream<[SerialPortDescriptor]> {
AsyncStream { continuation in
let monitor = SerialDeviceDirectoryMonitor(continuation: continuation)
monitor.start()
continuation.onTermination = { _ in monitor.stop() }
}
}
func open(
_ configuration: SerialConnectionConfiguration,
receive: @escaping @Sendable (Data) -> Void,
disconnected: @escaping @Sendable (String) -> Void,
progress: @escaping @Sendable (SerialPortOpenStage) -> Void
) async throws -> any SerialPortConnection {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<any SerialPortConnection, any Error>) in
let attempt = SerialPortOpenAttempt(continuation: continuation)
openQueue.async {
do {
let connection = try POSIXSerialPortConnection(
configuration: configuration, receive: receive,
disconnected: disconnected, progress: progress
)
if !attempt.succeed(connection) {
Task { await connection.close() }
}
} catch {
attempt.fail(error)
}
}
openQueue.asyncAfter(deadline: .now() + 5) {
attempt.fail(SerialPortError.openTimedOut)
}
}
}
fileprivate static func scanPorts() -> [SerialPortDescriptor] {
var names: [String: String] = [:]
if let matching = IOServiceMatching(kIOSerialBSDServiceValue) {
var iterator: io_iterator_t = 0
if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS {
defer { IOObjectRelease(iterator) }
while case let service = IOIteratorNext(iterator), service != 0 {
defer { IOObjectRelease(service) }
let product = property("USB Product Name", of: service)
?? property("Product Name", of: service)
?? property(kIOTTYBaseNameKey, of: service)
for key in [kIOCalloutDeviceKey, kIODialinDeviceKey] {
if let path = property(key, of: service) { names[path] = product }
}
}
}
}
// IOKit covers native macOS serial drivers. Also include likely character
// devices so third-party drivers exposing Linux-style ttyUSB/ttyACM names
// and user-entered development nodes are not silently hidden.
if let entries = try? FileManager.default.contentsOfDirectory(atPath: "/dev") {
for entry in entries where looksLikeSerialDevice(entry) {
let path = "/dev/\(entry)"
var info = stat()
if lstat(path, &info) == 0, (info.st_mode & S_IFMT) == S_IFCHR {
names[path] = names[path] ?? entry
}
}
}
return SerialPortDiscovery.deduplicated(
names.map { SerialPortDescriptor(path: $0.key, name: $0.value) }
)
}
private static func property(_ key: String, of service: io_registry_entry_t) -> String? {
IORegistryEntrySearchCFProperty(
service, kIOServicePlane, key as CFString, kCFAllocatorDefault,
IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)
) as? String
}
private static func looksLikeSerialDevice(_ name: String) -> Bool {
let lower = name.lowercased()
return lower.hasPrefix("cu.") || lower.hasPrefix("tty.")
|| lower.hasPrefix("ttyusb") || lower.hasPrefix("ttyacm")
|| lower.hasPrefix("rfcomm") || lower.hasPrefix("serial")
}
}
nonisolated private final class SerialPortOpenAttempt: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<any SerialPortConnection, any Error>?
init(continuation: CheckedContinuation<any SerialPortConnection, any Error>) {
self.continuation = continuation
}
func succeed(_ connection: any SerialPortConnection) -> Bool {
let continuation = takeContinuation()
continuation?.resume(returning: connection)
return continuation != nil
}
func fail(_ error: any Error) {
takeContinuation()?.resume(throwing: error)
}
private func takeContinuation() -> CheckedContinuation<any SerialPortConnection, any Error>? {
lock.lock()
defer { lock.unlock() }
let result = continuation
continuation = nil
return result
}
}
nonisolated enum SerialPortDiscovery {
/// Collapses exact repeats and macOS's `cu.*`/`tty.*` aliases. Callout wins
/// because it opens immediately; every retained row still displays its full
/// path, including Linux-style driver nodes such as `/dev/ttyUSB0`.
static func deduplicated(_ ports: [SerialPortDescriptor]) -> [SerialPortDescriptor] {
var preferred: [String: SerialPortDescriptor] = [:]
for port in ports {
let key = deviceIdentity(port.path)
guard let existing = preferred[key] else {
preferred[key] = port
continue
}
if priority(port.path) > priority(existing.path) { preferred[key] = port }
}
return preferred.values.sorted {
$0.path.localizedStandardCompare($1.path) == .orderedAscending
}
}
private static func deviceIdentity(_ path: String) -> String {
let name = URL(fileURLWithPath: path).lastPathComponent.lowercased()
if name.hasPrefix("cu.") { return String(name.dropFirst(3)) }
if name.hasPrefix("tty.") { return String(name.dropFirst(4)) }
return name
}
private static func priority(_ path: String) -> Int {
URL(fileURLWithPath: path).lastPathComponent.lowercased().hasPrefix("cu.") ? 1 : 0
}
}
/// Watches `/dev` for actual device-node changes. This replaces periodic IOKit
/// polling: IOSerialBSDClient is enumerated once initially and only again after
/// the kernel reports an insertion, removal, rename, or attribute change.
nonisolated private final class SerialDeviceDirectoryMonitor: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.akinokaede.termind.serial.discovery")
private let continuation: AsyncStream<[SerialPortDescriptor]>.Continuation
private var descriptor: Int32 = -1
private var source: (any DispatchSourceFileSystemObject)?
private var previous: [SerialPortDescriptor] = []
init(continuation: AsyncStream<[SerialPortDescriptor]>.Continuation) {
self.continuation = continuation
}
func start() {
queue.async { [self] in
publishIfChanged()
descriptor = Darwin.open("/dev", O_EVTONLY)
guard descriptor >= 0 else {
continuation.finish()
return
}
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: [.write, .rename, .delete, .attrib],
queue: queue
)
source.setEventHandler { [weak self] in self?.publishIfChanged() }
source.setCancelHandler { [weak self] in
guard let self, descriptor >= 0 else { return }
Darwin.close(descriptor)
descriptor = -1
}
self.source = source
source.resume()
}
}
func stop() {
queue.async { [weak self] in
self?.source?.cancel()
self?.source = nil
}
}
private func publishIfChanged() {
let current = SystemSerialPortService.scanPorts()
guard current != previous else { return }
previous = current
continuation.yield(current)
}
}
nonisolated private final class POSIXSerialPortConnection: SerialPortConnection,
@unchecked Sendable
{
private let queue = DispatchQueue(label: "com.akinokaede.termind.serial")
private var descriptor: Int32
private var source: (any DispatchSourceRead)!
private var reportsDisconnect = true
private let receive: @Sendable (Data) -> Void
private let disconnected: @Sendable (String) -> Void
init(
configuration: SerialConnectionConfiguration,
receive: @escaping @Sendable (Data) -> Void,
disconnected: @escaping @Sendable (String) -> Void,
progress: @escaping @Sendable (SerialPortOpenStage) -> Void
) throws {
guard configuration.isValid else {
throw SerialPortError.invalidConfiguration(
String(localized: "Enter an absolute device path and a valid baud rate.")
)
}
self.receive = receive
self.disconnected = disconnected
let path = configuration.normalizedDevicePath
descriptor = Darwin.open(path, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard descriptor >= 0 else {
throw SerialPortError.system(operation: String(localized: "Open serial port"), code: errno)
}
progress(.deviceOpened)
do {
guard ioctl(descriptor, TIOCEXCL) == 0 else {
throw SerialPortError.system(
operation: String(localized: "Open serial port"), code: errno
)
}
try Self.configure(descriptor, with: configuration)
progress(.settingsApplied)
} catch {
Darwin.close(descriptor)
throw error
}
source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue)
source.setEventHandler { [weak self] in self?.readAvailableBytes() }
source.resume()
progress(.monitoringStarted)
}
func write(_ data: Data) async throws {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, any Error>) in
queue.async { [weak self] in
guard let self, descriptor >= 0 else {
continuation.resume(throwing: SerialPortError.closed)
return
}
do {
try data.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
var offset = 0
while offset < raw.count {
let count = Darwin.write(descriptor, base.advanced(by: offset), raw.count - offset)
if count > 0 { offset += count; continue }
if count < 0, errno == EINTR { continue }
throw SerialPortError.system(
operation: String(localized: "Write serial port"), code: errno
)
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func close() async {
await withCheckedContinuation { continuation in
queue.async { [weak self] in
self?.reportsDisconnect = false
self?.finish(nil)
continuation.resume()
}
}
}
private func readAvailableBytes() {
guard descriptor >= 0 else { return }
var bytes = [UInt8](repeating: 0, count: 16_384)
let count = Darwin.read(descriptor, &bytes, bytes.count)
if count > 0 {
receive(Data(bytes.prefix(count)))
} else if count == 0 || (errno != EAGAIN && errno != EINTR) {
let code = count == 0 ? ENXIO : errno
finish(SerialPortError.system(
operation: String(localized: "Read serial port"), code: code
).localizedDescription)
}
}
private func finish(_ message: String?) {
guard descriptor >= 0 else { return }
let fd = descriptor
descriptor = -1
source.cancel()
Darwin.close(fd)
if reportsDisconnect, let message { disconnected(message) }
}
private static func configure(
_ descriptor: Int32, with configuration: SerialConnectionConfiguration
) throws {
var options = termios()
guard tcgetattr(descriptor, &options) == 0 else {
throw SerialPortError.system(operation: String(localized: "Read serial settings"), code: errno)
}
cfmakeraw(&options)
options.c_cflag |= tcflag_t(CLOCAL | CREAD)
options.c_cflag &= ~tcflag_t(CSIZE | PARENB | PARODD | CSTOPB | CRTSCTS)
switch configuration.dataBits {
case .five: options.c_cflag |= tcflag_t(CS5)
case .six: options.c_cflag |= tcflag_t(CS6)
case .seven: options.c_cflag |= tcflag_t(CS7)
case .eight: options.c_cflag |= tcflag_t(CS8)
}
switch configuration.parity {
case .none: break
case .even: options.c_cflag |= tcflag_t(PARENB)
case .odd: options.c_cflag |= tcflag_t(PARENB | PARODD)
}
if configuration.stopBits == .two { options.c_cflag |= tcflag_t(CSTOPB) }
options.c_iflag &= ~tcflag_t(IXON | IXOFF | IXANY)
switch configuration.flowControl {
case .none: break
case .software: options.c_iflag |= tcflag_t(IXON | IXOFF)
case .hardware: options.c_cflag |= tcflag_t(CRTSCTS)
}
let usesStandardSpeed = [
300, 600, 1_200, 2_400, 4_800, 9_600, 19_200, 38_400,
57_600, 115_200, 230_400,
].contains(configuration.baudRate)
let termiosSpeed = speed_t(usesStandardSpeed ? configuration.baudRate : 9_600)
guard cfsetspeed(&options, termiosSpeed) == 0 else {
throw SerialPortError.system(operation: String(localized: "Set baud rate"), code: errno)
}
guard tcsetattr(descriptor, TCSANOW, &options) == 0 else {
throw SerialPortError.system(operation: String(localized: "Apply serial settings"), code: errno)
}
if !usesStandardSpeed {
var speed = speed_t(configuration.baudRate)
// IOSSIOSPEED is an object-like C macro and is not imported into Swift.
// _IOW('T', 2, speed_t) on 64-bit Darwin expands to this request value.
let setArbitrarySpeed = UInt(0x8008_5402)
guard ioctl(descriptor, setArbitrarySpeed, &speed) == 0 else {
throw SerialPortError.system(operation: String(localized: "Set baud rate"), code: errno)
}
}
}
}
+447 -22
View File
@@ -1593,6 +1593,16 @@
}
}
},
"Apply serial settings" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "应用串口设置"
}
}
}
},
"Approval required" : {
"localizations" : {
"zh-Hans" : {
@@ -1893,6 +1903,16 @@
}
}
},
"Baud Rate" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "波特率"
}
}
}
},
"Bearer Token" : {
"localizations" : {
"zh-Hans" : {
@@ -2629,6 +2649,27 @@
}
}
},
"Common Rates" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "常用速率"
}
}
}
},
"Communication" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "通信参数"
}
}
}
},
"Compact" : {
"localizations" : {
"zh-Hans" : {
@@ -2715,6 +2756,17 @@
}
}
},
"Configure serial port" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "配置串口"
}
}
}
},
"Confirm Passphrase" : {
"localizations" : {
"zh-Hans" : {
@@ -3245,6 +3297,16 @@
}
}
},
"CR" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "CR"
}
}
}
},
"create" : {
"localizations" : {
"zh-Hans" : {
@@ -3305,6 +3367,16 @@
}
}
},
"CRLF" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "CRLF"
}
}
}
},
"Current" : {
"localizations" : {
"zh-Hans" : {
@@ -3365,6 +3437,17 @@
}
}
},
"Custom path" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "自定义路径"
}
}
}
},
"Customize" : {
"localizations" : {
"zh-Hans" : {
@@ -3395,6 +3478,16 @@
}
}
},
"Data Bits" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "数据位"
}
}
}
},
"Date Modified" : {
"comment" : "A column header for the date modified column.",
"isCommentAutoGenerated" : true,
@@ -3717,6 +3810,37 @@
}
}
},
"Detected Path" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "检测到的路径"
}
}
}
},
"Device" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设备"
}
}
}
},
"Device Path" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设备路径"
}
}
}
},
"Did not run “%@”" : {
"localizations" : {
"zh-Hans" : {
@@ -4169,6 +4293,26 @@
}
}
},
"Enter an absolute device path and a valid baud rate." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请输入绝对设备路径和有效的波特率。"
}
}
}
},
"Enter Sends" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "回车键发送"
}
}
}
},
"Enter the Passphrase for the selected key." : {
"localizations" : {
"zh-Hans" : {
@@ -4189,6 +4333,16 @@
}
}
},
"Even" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "偶校验"
}
}
}
},
"Every model searches Google, Bing, DuckDuckGo, and Yahoo from this Mac and reads result pages as Markdown." : {
"localizations" : {
"zh-Hans" : {
@@ -4665,6 +4819,16 @@
}
}
},
"Flow Control" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "流控制"
}
}
}
},
"Folder" : {
"comment" : "Description of a file type when the file is a directory.",
"isCommentAutoGenerated" : true,
@@ -5851,6 +6015,16 @@
}
}
},
"LF" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "LF"
}
}
}
},
"libssh2" : {
"localizations" : {
"zh-Hans" : {
@@ -5961,6 +6135,16 @@
}
}
},
"Local Echo" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "本地回显"
}
}
}
},
"Local Forward" : {
"localizations" : {
"zh-Hans" : {
@@ -6583,6 +6767,16 @@
}
}
},
"No command is running under that handle. It finished and was already reported, or it was released after going unasked-about for too long." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "该句柄下没有正在运行的命令。命令可能已结束并报告结果,或因长时间无人查询而被释放。"
}
}
}
},
"No commands match “%@”" : {
"comment" : "A label that indicates that no commands match the current search query. The argument is the query.",
"isCommentAutoGenerated" : true,
@@ -6595,16 +6789,6 @@
}
}
},
"No command is running under that handle. It finished and was already reported, or it was released after going unasked-about for too long." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "该句柄下没有正在运行的命令。命令可能已结束并报告结果,或因长时间无人查询而被释放。"
}
}
}
},
"No Conversations" : {
"localizations" : {
"zh-Hans" : {
@@ -6615,6 +6799,28 @@
}
}
},
"No detected serial devices" : {
"comment" : "A message displayed when there are no serial devices detected.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "未检测到串口设备"
}
}
}
},
"No detected serial devices. You can still enter any absolute device path." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "未检测到串口设备。仍可输入任意绝对设备路径。"
}
}
}
},
"No differences" : {
"localizations" : {
"zh-Hans" : {
@@ -7089,6 +7295,16 @@
}
}
},
"Odd" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "奇校验"
}
}
}
},
"Off" : {
"localizations" : {
"zh-Hans" : {
@@ -7229,6 +7445,16 @@
}
}
},
"Open serial port" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开串口"
}
}
}
},
"Open System Settings" : {
"localizations" : {
"zh-Hans" : {
@@ -7259,6 +7485,18 @@
}
}
},
"Opening the serial port timed out." : {
"comment" : "Error description when opening the serial port times out.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开串口超时。"
}
}
}
},
"OpenSSH commands are unavailable for routes using an HTTP or SOCKS5 proxy." : {
"localizations" : {
"zh-Hans" : {
@@ -7389,6 +7627,16 @@
}
}
},
"Parity" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "校验"
}
}
}
},
"Passphrase" : {
"localizations" : {
"zh-Hans" : {
@@ -8165,6 +8413,26 @@
}
}
},
"Read serial port" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "读取串口"
}
}
}
},
"Read serial settings" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "读取串口设置"
}
}
}
},
"Read-only" : {
"localizations" : {
"zh-Hans" : {
@@ -8245,6 +8513,16 @@
}
}
},
"Reconnect" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "重新连接"
}
}
}
},
"Reconnect attempt %lld · retrying in %llds" : {
"localizations" : {
"en" : {
@@ -8887,6 +9165,16 @@
}
}
},
"RTS/CTS" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "RTS/CTS"
}
}
}
},
"run" : {
"localizations" : {
"zh-Hans" : {
@@ -9499,6 +9787,63 @@
}
}
},
"Serial" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口"
}
}
}
},
"Serial device opened" : {
"comment" : "Message displayed when the serial port is successfully opened.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口设备已打开"
}
}
}
},
"Serial input monitoring started" : {
"comment" : "Message for the serial port open stage when serial input monitoring has started.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口输入监听已启动"
}
}
}
},
"Serial Port Disconnected" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口已断开"
}
}
}
},
"Serial settings applied" : {
"comment" : "Message displayed when the serial port settings have been successfully applied.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口设置已应用"
}
}
}
},
"Served from cache" : {
"localizations" : {
"zh-Hans" : {
@@ -9549,6 +9894,16 @@
}
}
},
"Set baud rate" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设置波特率"
}
}
}
},
"Settings" : {
"localizations" : {
"zh-Hans" : {
@@ -10311,6 +10666,16 @@
}
}
},
"Stop Bits" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止位"
}
}
}
},
"Stop current run — ⌘." : {
"localizations" : {
"zh-Hans" : {
@@ -10669,6 +11034,16 @@
}
}
},
"Terminal Input" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "终端输入"
}
}
}
},
"Terminal ready" : {
"localizations" : {
"zh-Hans" : {
@@ -10925,6 +11300,26 @@
}
}
},
"The device path does not exist." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设备路径不存在。"
}
}
}
},
"The device path is not a character device." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设备路径不是字符设备。"
}
}
}
},
"The differing region spans %lld lines, past the %lld-line diff limit. Narrow it with a bounded read of each side instead." : {
"localizations" : {
"en" : {
@@ -11683,6 +12078,16 @@
}
}
},
"The serial port is closed." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "串口已关闭。"
}
}
}
},
"The server answered %lld to a Streamable HTTP request. It may only speak the legacy SSE transport — try switching Transport." : {
"localizations" : {
"zh-Hans" : {
@@ -13227,18 +13632,6 @@
}
}
},
"Workspaces" : {
"comment" : "Title of a command section for workspace management.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "工作区"
}
}
}
},
"Workspace" : {
"localizations" : {
"zh-Hans" : {
@@ -13289,6 +13682,18 @@
}
}
},
"Workspaces" : {
"comment" : "Title of a command section for workspace management.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "工作区"
}
}
}
},
"write" : {
"localizations" : {
"zh-Hans" : {
@@ -13309,6 +13714,16 @@
}
}
},
"Write serial port" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "写入串口"
}
}
}
},
"Writes directly to a block device." : {
"localizations" : {
"zh-Hans" : {
@@ -13329,6 +13744,16 @@
}
}
},
"XON/XOFF" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "XON/XOFF"
}
}
}
},
"Yellow" : {
"localizations" : {
"zh-Hans" : {
+3
View File
@@ -7,6 +7,9 @@
<!-- Voice transcription in the Chat composer. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Local serial devices exposed under /dev. -->
<key>com.apple.security.device.serial</key>
<true/>
<!-- Outgoing SSH / SFTP connections and LLM endpoint calls. -->
<key>com.apple.security.network.client</key>
<true/>
@@ -136,6 +136,9 @@ struct SSHConnectionJourneyView: View {
var reconnectState: ReconnectState? = nil
var retryReconnectNow: (() -> Void)? = nil
var stopReconnecting: (() -> Void)? = nil
var failureCancelLabel: LocalizedStringKey = "Cancel"
var failureRetryLabel: LocalizedStringKey = "Retry"
var compactFailureActions = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -178,10 +181,7 @@ struct SSHConnectionJourneyView: View {
.fill(Color.accentColor.gradient)
.frame(width: 52, height: 52)
.overlay {
Image(
systemName: journey.purpose == .sshSession
? "apple.terminal.fill" : "folder.fill.badge.gearshape"
)
Image(systemName: headerSymbol)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
}
@@ -276,7 +276,10 @@ struct SSHConnectionJourneyView: View {
} else if case .failed(let message) = journey.phase {
JourneyFailureCard(
message: message, retry: retry, cancel: cancel,
copyLogs: copyLogs, askAI: askAI
copyLogs: copyLogs, askAI: askAI,
cancelLabel: failureCancelLabel,
retryLabel: failureRetryLabel,
compactActions: compactFailureActions
)
} else if journey.mode == .reconnection, let reconnectState {
JourneyReconnectStatus(
@@ -335,15 +338,21 @@ struct SSHConnectionJourneyView: View {
case .connecting: String(localized: "Establishing connection…")
case .waiting: String(localized: "Waiting for you…")
case .succeeded:
journey.purpose == .sshSession
? String(localized: "Terminal ready") : String(localized: "File browser ready")
switch journey.purpose {
case .sshSession, .serialSession: String(localized: "Terminal ready")
case .sftpSession: String(localized: "File browser ready")
}
case .failed(let message): message
case .hidden: ""
}
}
private var headerDetail: String {
let protocolName = journey.purpose == .sshSession ? "SSH" : "SFTP"
let protocolName: String = switch journey.purpose {
case .sshSession: "SSH"
case .sftpSession: "SFTP"
case .serialSession: String(localized: "Serial")
}
let address = journey.destination?.address ?? ""
if journey.mode == .reconnection {
return String(localized: "Reconnecting \(protocolName) · \(address)")
@@ -351,6 +360,14 @@ struct SSHConnectionJourneyView: View {
return "\(protocolName) \(address)"
}
private var headerSymbol: String {
switch journey.purpose {
case .sshSession: "apple.terminal.fill"
case .sftpSession: "folder.fill.badge.gearshape"
case .serialSession: "cable.connector"
}
}
private var statusSymbol: String {
switch journey.phase {
case .succeeded: "checkmark.circle.fill"
@@ -645,7 +662,11 @@ private struct JourneyEndpointView: View {
}
case .jump: "server.rack"
case .destination:
purpose == .sshSession ? "apple.terminal.fill" : "folder.fill"
switch purpose {
case .sshSession: "apple.terminal.fill"
case .sftpSession: "folder.fill"
case .serialSession: "cable.connector"
}
}
}
@@ -1168,22 +1189,42 @@ private struct JourneyFailureCard: View {
let cancel: () -> Void
let copyLogs: () -> Void
let askAI: (() -> Void)?
let cancelLabel: LocalizedStringKey
let retryLabel: LocalizedStringKey
let compactActions: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Connection Failed", systemImage: "exclamationmark.triangle.fill")
.font(.headline).foregroundStyle(.red)
Text(message).foregroundStyle(.secondary).textSelection(.enabled)
HStack {
Button("Cancel", action: cancel).keyboardShortcut(.cancelAction)
Button("Copy Logs", action: copyLogs)
if let askAI { Button("Ask AI", action: askAI) }
Spacer()
Button("Retry", action: retry)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
if compactActions {
HStack {
Button(action: cancel) {
Text(cancelLabel).frame(width: 88)
}
.buttonStyle(.bordered)
.keyboardShortcut(.cancelAction)
Spacer()
Button(action: retry) {
Text(retryLabel).frame(width: 88)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
} else {
HStack {
Button(cancelLabel, action: cancel).keyboardShortcut(.cancelAction)
Button("Copy Logs", action: copyLogs)
if let askAI { Button("Ask AI", action: askAI) }
Spacer()
Button(retryLabel, action: retry)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
}
}
}
.padding(18)
.padding(compactActions ? 14 : 18)
.frame(maxWidth: compactActions ? 380 : nil)
.background(Color.red.opacity(0.065), in: .rect(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(Color.red.opacity(0.3)) }
}
+29 -9
View File
@@ -151,6 +151,7 @@ struct ManagedChooserHeader: View {
let prompt: LocalizedStringKey
@Binding var query: String
var aboveSearch: AnyView? = nil
var showsSearch = true
var onSubmit: () -> Void = {}
var moveCursor: (Int) -> KeyPress.Result = { _ in .ignored }
var toggleCursor: () -> KeyPress.Result = { .ignored }
@@ -167,7 +168,29 @@ struct ManagedChooserHeader: View {
aboveSearch
}
HStack(spacing: Metrics.Space.s) {
if showsSearch {
searchRow
.transition(
.opacity.combined(with: .move(edge: .top))
)
}
}
.padding(Metrics.Space.xl)
.animation(Motion.panel, value: showsSearch)
.onAppear {
if showsSearch { searchFocused = true }
}
.onChange(of: showsSearch) { _, visible in
if visible {
Task { @MainActor in searchFocused = true }
} else {
searchFocused = false
}
}
}
private var searchRow: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(prompt, text: $query)
@@ -189,15 +212,12 @@ struct ManagedChooserHeader: View {
.buttonStyle(.plain)
.accessibilityLabel(Text("Clear search"))
}
}
.padding(Metrics.Space.s)
.background(
.quaternary.opacity(0.5),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
}
.padding(Metrics.Space.xl)
.onAppear { searchFocused = true }
.padding(Metrics.Space.s)
.background(
.quaternary.opacity(0.5),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
}
}
+12
View File
@@ -635,6 +635,18 @@ extension WindowModel {
focusRequest: findFocusRequest
)
case .terminal:
if let serialSession = focusedSerialSession {
return WindowToolbarFindContext(
kind: kind,
query: Binding(
get: { serialSession.findQuery },
set: { serialSession.updateFindQuery($0) }
),
focusRequest: findFocusRequest,
navigate: { serialSession.presentFindAndNavigate($0) },
cancel: { serialSession.dismissFind() }
)
}
guard let sshSession = focusedSSHSession else { return nil }
return WindowToolbarFindContext(
kind: kind,
+130
View File
@@ -0,0 +1,130 @@
import Foundation
import Testing
@testable import Termind
nonisolated private struct InertSerialConnection: SerialPortConnection {
func write(_: Data) async throws {}
func close() async {}
}
nonisolated private struct InertSerialService: SerialPortServing {
func availablePorts() async -> [SerialPortDescriptor] { [] }
func portUpdates() -> AsyncStream<[SerialPortDescriptor]> {
AsyncStream { $0.finish() }
}
func open(
_: SerialConnectionConfiguration,
receive _: @escaping @Sendable (Data) -> Void,
disconnected _: @escaping @Sendable (String) -> Void,
progress _: @escaping @Sendable (SerialPortOpenStage) -> Void
) async throws -> any SerialPortConnection {
InertSerialConnection()
}
}
@Suite
struct SerialFeatureTests {
@Test
func defaultConfigurationIs115200EightNOneWithRawCarriageReturn() {
var configuration = SerialConnectionConfiguration()
configuration.devicePath = "/dev/ttyUSB0"
#expect(configuration.isValid)
#expect(configuration.summary == "115200 8-N-1")
#expect(configuration.flowControl == .none)
#expect(configuration.enterKey == .carriageReturn)
#expect(!configuration.localEcho)
#expect(configuration.transformInteractiveInput(Data("a\r".utf8)) == Data("a\r".utf8))
}
@Test
func enterMappingChangesOnlyCarriageReturnBytes() {
var configuration = SerialConnectionConfiguration()
configuration.enterKey = .lineFeed
#expect(configuration.transformInteractiveInput(Data([0x41, 0x0D, 0x42, 0x0A])) == Data([0x41, 0x0A, 0x42, 0x0A]))
configuration.enterKey = .carriageReturnLineFeed
#expect(configuration.transformInteractiveInput(Data([0x0D])) == Data([0x0D, 0x0A]))
}
@Test
func validationAcceptsCustomAbsolutePathsAndRejectsBadBaudRates() {
var configuration = SerialConnectionConfiguration(devicePath: " /dev/ttyACM0 ")
#expect(configuration.isValid)
#expect(configuration.normalizedDevicePath == "/dev/ttyACM0")
configuration.baudRate = 0
#expect(!configuration.isValid)
configuration.baudRate = 4_000_001
#expect(!configuration.isValid)
}
@MainActor
@Test
func editableComboKeepsTypedValuesAndPresetMetadataDistinct() {
let option = SerialComboOption(value: "/dev/cu.usbserial-01", label: "USB UART — /dev/cu.usbserial-01")
#expect(option.id == option.value)
#expect(option.label.contains(option.value))
let rates = SerialConnectionConfiguration.commonBaudRates.map(String.init)
#expect(rates.contains("115200"))
#expect(rates.contains("4000000"))
}
@Test
func discoveryCollapsesCalloutAndDialInAliasesWithoutHidingCustomTTYPaths() {
let ports = SerialPortDiscovery.deduplicated([
SerialPortDescriptor(path: "/dev/tty.usbserial-01", name: "USB UART"),
SerialPortDescriptor(path: "/dev/cu.usbserial-01", name: "USB UART"),
SerialPortDescriptor(path: "/dev/cu.usbserial-01", name: "USB UART"),
SerialPortDescriptor(path: "/dev/ttyUSB0", name: "ttyUSB0"),
])
#expect(ports.map(\.path) == ["/dev/cu.usbserial-01", "/dev/ttyUSB0"])
}
@MainActor
@Test
func serialConnectionUsesTheSharedConnectionJourney() {
let journey = SSHConnectionJourney()
journey.beginSerial(name: "USB UART", address: "/dev/cu.usbserial-01")
#expect(journey.purpose == .serialSession)
#expect(journey.phase == .connecting)
#expect(journey.endpoints.count == 2)
#expect(journey.destination?.name == "USB UART")
#expect(journey.destination?.address == "/dev/cu.usbserial-01")
journey.fail("Device busy")
#expect(journey.phase == .failed("Device busy"))
}
@Test
func serialOpenStagesDescribeTheReadinessBoundary() {
#expect(SerialPortOpenStage.deviceOpened.message == "Serial device opened")
#expect(SerialPortOpenStage.settingsApplied.message == "Serial settings applied")
#expect(SerialPortOpenStage.monitoringStarted.message == "Serial input monitoring started")
}
@MainActor
@Test
func serialPaneParticipatesInSessionsAndFindButCannotBeDuplicated() {
let configuration = SerialConnectionConfiguration(
devicePath: "/dev/ttyUSB0", deviceName: "USB UART"
)
let pane = Pane.serialSession(configuration: configuration, service: InertSerialService())
let workspace = Workspace(pane: pane)
let window = WindowModel(workspaces: [workspace])
window.select(workspace.id)
#expect(pane.kind == .serialSession)
#expect(pane.symbol == "cable.connector")
#expect(pane.serialSession?.title == "USB UART")
#expect(window.sessionPanes.map(\.pane.id) == [pane.id])
#expect(window.serialSessionPanes.map(\.pane.id) == [pane.id])
#expect(window.canFindInTerminal)
#expect(window.canSplitSelected)
#expect(!window.canDuplicateSelected)
}
}