feat: add serial terminal sessions
This commit is contained in:
1 parent
74292a9135
commit
9e4175485f
21 files changed
+1875
-78
No files matched your search
+6
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user