diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 73d8b643..f086a20c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Termind/App/AppDependencies.swift b/Termind/App/AppDependencies.swift index c4527a69..f3f24a16 100644 --- a/Termind/App/AppDependencies.swift +++ b/Termind/App/AppDependencies.swift @@ -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 } diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 6db7a129..0e0604b8 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -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, diff --git a/Termind/Application/Connections/SSHConnectionJourney.swift b/Termind/Application/Connections/SSHConnectionJourney.swift index b80e0f4c..e071bd18 100644 --- a/Termind/Application/Connections/SSHConnectionJourney.swift +++ b/Termind/Application/Connections/SSHConnectionJourney.swift @@ -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" + } + } +} diff --git a/Termind/Application/Serial/SerialPortService.swift b/Termind/Application/Serial/SerialPortService.swift new file mode 100644 index 00000000..f39bc66e --- /dev/null +++ b/Termind/Application/Serial/SerialPortService.swift @@ -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 +} diff --git a/Termind/Domain/Connections/SerialConnection.swift b/Termind/Domain/Connections/SerialConnection.swift new file mode 100644 index 00000000..01f61150 --- /dev/null +++ b/Termind/Domain/Connections/SerialConnection.swift @@ -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 + } +} diff --git a/Termind/Features/HostChooser/HostChooserView.swift b/Termind/Features/HostChooser/HostChooserView.swift index fa1a9215..567dafa2 100644 --- a/Termind/Features/HostChooser/HostChooserView.swift +++ b/Termind/Features/HostChooser/HostChooserView.swift @@ -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 @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() } } diff --git a/Termind/Features/StatusBar/StatusBarView.swift b/Termind/Features/StatusBar/StatusBarView.swift index 0fd35fa9..6b3fa5c4 100644 --- a/Termind/Features/StatusBar/StatusBarView.swift +++ b/Termind/Features/StatusBar/StatusBarView.swift @@ -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) } } diff --git a/Termind/Features/Terminal/SerialControls.swift b/Termind/Features/Terminal/SerialControls.swift new file mode 100644 index 00000000..ea3e76b5 --- /dev/null +++ b/Termind/Features/Terminal/SerialControls.swift @@ -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) + } +} diff --git a/Termind/Features/Terminal/SerialSessionModel.swift b/Termind/Features/Terminal/SerialSessionModel.swift new file mode 100644 index 00000000..8e4a4d16 --- /dev/null +++ b/Termind/Features/Terminal/SerialSessionModel.swift @@ -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? + 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 + ) + } + } +} diff --git a/Termind/Features/Workspaces/Pane.swift b/Termind/Features/Workspaces/Pane.swift index 32c04d77..2533e111 100644 --- a/Termind/Features/Workspaces/Pane.swift +++ b/Termind/Features/Workspaces/Pane.swift @@ -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 { diff --git a/Termind/Features/Workspaces/WindowModel.swift b/Termind/Features/Workspaces/WindowModel.swift index 896ff392..cc9d0985 100644 --- a/Termind/Features/Workspaces/WindowModel.swift +++ b/Termind/Features/Workspaces/WindowModel.swift @@ -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 diff --git a/Termind/Features/Workspaces/Workspace.swift b/Termind/Features/Workspaces/Workspace.swift index 4159f3f4..52ef80b9 100644 --- a/Termind/Features/Workspaces/Workspace.swift +++ b/Termind/Features/Workspaces/Workspace.swift @@ -205,6 +205,7 @@ final class Workspace: Identifiable { func markRead() { for pane in panes { pane.sshSession?.hasUnreadOutput = false + pane.serialSession?.hasUnreadOutput = false } } diff --git a/Termind/Features/Workspaces/WorkspaceContentView.swift b/Termind/Features/Workspaces/WorkspaceContentView.swift index b979deb8..448fc573 100644 --- a/Termind/Features/Workspaces/WorkspaceContentView.swift +++ b/Termind/Features/Workspaces/WorkspaceContentView.swift @@ -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 } diff --git a/Termind/Infrastructure/Serial/SystemSerialPortService.swift b/Termind/Infrastructure/Serial/SystemSerialPortService.swift new file mode 100644 index 00000000..97e943cf --- /dev/null +++ b/Termind/Infrastructure/Serial/SystemSerialPortService.swift @@ -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) 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? + + init(continuation: CheckedContinuation) { + 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? { + 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) 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) + } + } + } +} diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 3e8a8b84..53884922 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Termind/Termind.entitlements b/Termind/Termind.entitlements index 75582bbc..a67a7705 100644 --- a/Termind/Termind.entitlements +++ b/Termind/Termind.entitlements @@ -7,6 +7,9 @@ com.apple.security.device.audio-input + + com.apple.security.device.serial + com.apple.security.network.client diff --git a/Termind/UI/Components/SSHConnectionJourneyView.swift b/Termind/UI/Components/SSHConnectionJourneyView.swift index 49916834..2cb416ef 100644 --- a/Termind/UI/Components/SSHConnectionJourneyView.swift +++ b/Termind/UI/Components/SSHConnectionJourneyView.swift @@ -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)) } } diff --git a/Termind/UI/Components/SharedChrome.swift b/Termind/UI/Components/SharedChrome.swift index d6c2b427..57940fbe 100644 --- a/Termind/UI/Components/SharedChrome.swift +++ b/Termind/UI/Components/SharedChrome.swift @@ -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) + ) } } diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index d9084566..73a44ab4 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -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, diff --git a/TermindTests/SerialFeatureTests.swift b/TermindTests/SerialFeatureTests.swift new file mode 100644 index 00000000..a011d3ac --- /dev/null +++ b/TermindTests/SerialFeatureTests.swift @@ -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) + } +}