refactor(connections): centralize default terminal resolution
Share default transport ordering and connection-kind resolution across desktop and mobile opening paths. Keep explicit transport choices intact and skip unavailable default terminals before admission. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
58686b400b
commit
8b6325db7b
14 files changed
+197
-68
No files matched your search
@@ -28,6 +28,32 @@ struct TerminalTransportTests {
|
||||
#expect(host.terminalTransports.eternalTerminalEnabled)
|
||||
}
|
||||
|
||||
@Test(arguments: [HostTerminalProtocol.ssh, .mosh, .eternalTerminal])
|
||||
func preferredOrderingAndConnectionKindShareTheDefaultPolicy(transport: HostTerminalProtocol) {
|
||||
var host = Host(name: "Server", host: "server.invalid", telnetEnabled: true)
|
||||
host.terminalTransports.moshEnabled = true
|
||||
host.terminalTransports.eternalTerminalEnabled = true
|
||||
host.terminalTransports.defaultTransport = transport
|
||||
let canonical = [HostTerminalProtocol.ssh, .mosh, .eternalTerminal, .telnet]
|
||||
|
||||
#expect(host.supportedTerminalProtocols == canonical)
|
||||
#expect(host.preferredFirstTerminalProtocols == [transport] + canonical.filter { $0 != transport })
|
||||
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .terminal(transport))
|
||||
|
||||
host.terminalTransports.moshEnabled = false
|
||||
host.terminalTransports.eternalTerminalEnabled = false
|
||||
#expect(host.preferredFirstTerminalProtocols == [.ssh, .telnet])
|
||||
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .ssh)
|
||||
|
||||
host.sshEnabled = false
|
||||
#expect(host.preferredFirstTerminalProtocols == [.telnet])
|
||||
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == .telnet)
|
||||
|
||||
host.telnetEnabled = false
|
||||
#expect(host.preferredFirstTerminalProtocols.isEmpty)
|
||||
#expect(ConnectionHistoryEntry.Kind.preferredTerminal(for: host) == nil)
|
||||
}
|
||||
|
||||
@Test func rangeValidationCannotInjectBootstrapArguments() {
|
||||
for valid in ["", "1", "65535", "60001-60999", "1-65535"] {
|
||||
#expect(HostTerminalTransports.isValidMoshPortRange(valid))
|
||||
|
||||
@@ -35,6 +35,10 @@ public nonisolated struct ConnectionHistoryEntry: Identifiable, Codable, Hashabl
|
||||
}
|
||||
}
|
||||
|
||||
public static func preferredTerminal(for host: Host) -> Self? {
|
||||
host.preferredTerminalProtocol.map(terminal)
|
||||
}
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .ssh: String(localized: "SSH")
|
||||
|
||||
@@ -249,6 +249,11 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
return nil
|
||||
}
|
||||
|
||||
public var preferredFirstTerminalProtocols: [HostTerminalProtocol] {
|
||||
guard let preferred = preferredTerminalProtocol else { return [] }
|
||||
return [preferred] + supportedTerminalProtocols.filter { $0 != preferred }
|
||||
}
|
||||
|
||||
public func supports(_ requirement: HostProtocolRequirement) -> Bool {
|
||||
switch requirement {
|
||||
case .anyTerminal: preferredTerminalProtocol != nil
|
||||
|
||||
@@ -231,8 +231,8 @@ final class MobileSceneModel {
|
||||
func activateHost(_ hostID: Host.ID) {
|
||||
guard let host = app.library.host(hostID) else { return }
|
||||
guard let workspace = workspaceActivatedByHostRow(for: host) else {
|
||||
guard let connectionProtocol = host.preferredTerminalProtocol else { return }
|
||||
open(hostID: hostID, kind: .terminal(connectionProtocol))
|
||||
guard let kind = ConnectionHistoryEntry.Kind.preferredTerminal(for: host) else { return }
|
||||
open(hostID: hostID, kind: kind)
|
||||
return
|
||||
}
|
||||
selectWorkspace(workspace.id)
|
||||
|
||||
@@ -233,11 +233,9 @@ final class MobileWorkspaceSessionModel {
|
||||
let wanted = Set(hostIDs)
|
||||
let targets = app.library.hosts(matching: "", for: .snippetExecution)
|
||||
.filter { wanted.contains($0.id) }
|
||||
let opened = targets.map { host in
|
||||
makeWorkspace(
|
||||
host: host,
|
||||
kind: .terminal(host.terminalTransports.preferred(isTailscaleManaged: host.isTailscaleManaged)),
|
||||
startup: .command(snippet.body))
|
||||
let opened = targets.compactMap { host -> MobileWorkspace? in
|
||||
guard let kind = ConnectionHistoryEntry.Kind.preferredTerminal(for: host) else { return nil }
|
||||
return makeWorkspace(host: host, kind: kind, startup: .command(snippet.body))
|
||||
}
|
||||
addWorkspaces(opened)
|
||||
return opened.map(\.id)
|
||||
|
||||
@@ -17,7 +17,7 @@ nonisolated enum MobileConnectionProtocol: Hashable {
|
||||
func kind(for host: Host) -> ConnectionHistoryEntry.Kind? {
|
||||
guard host.supports(useCase.protocolRequirement) else { return nil }
|
||||
switch self {
|
||||
case .ssh: return host.preferredTerminalProtocol.map(ConnectionHistoryEntry.Kind.terminal)
|
||||
case .ssh: return .preferredTerminal(for: host)
|
||||
case .sftp: return .sftp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +99,7 @@ enum MobileConnectionHistory {
|
||||
}
|
||||
|
||||
static func preferredKind(for host: Host) -> ConnectionHistoryEntry.Kind? {
|
||||
switch host.preferredTerminalProtocol {
|
||||
case .ssh: .ssh
|
||||
case .mosh: .mosh
|
||||
case .eternalTerminal: .eternalTerminal
|
||||
case .telnet: .telnet
|
||||
case nil: nil
|
||||
}
|
||||
.preferredTerminal(for: host)
|
||||
}
|
||||
|
||||
private static func matches(
|
||||
|
||||
@@ -28,6 +28,35 @@ struct MobileBatchStartupTests {
|
||||
#expect(selections == [.sftp, .sftp])
|
||||
}
|
||||
|
||||
@Test(arguments: HostTerminalProtocol.allCases)
|
||||
func pickerRecentsAndBatchUseTheSameDefaultTransport(transport: HostTerminalProtocol) throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer {
|
||||
app.testWorkspaceSession.closeAll()
|
||||
app.outbox.stop()
|
||||
}
|
||||
var host = Host(name: "Default", host: "server.invalid", telnetEnabled: true)
|
||||
host.sshEnabled = transport != .telnet
|
||||
host.terminalTransports.moshEnabled = true
|
||||
host.terminalTransports.eternalTerminalEnabled = true
|
||||
if transport != .telnet { host.terminalTransports.defaultTransport = transport }
|
||||
#expect(app.library.saveHost(host))
|
||||
var selections: [ConnectionHistoryEntry.Kind] = []
|
||||
let picker = MobileConnectionPickerModel { _, kind in selections.append(kind) }
|
||||
|
||||
picker.select(host)
|
||||
#expect(selections == [.terminal(transport)])
|
||||
#expect(MobileConnectionHistory.preferredKind(for: host) == .terminal(transport))
|
||||
let ids = app.testWorkspaceSession.batchStartup(hostIDs: [host.id], connectionProtocol: .ssh)
|
||||
#expect(ids.count == 1)
|
||||
#expect(app.workspaces.last?.descriptor.kind == .terminal(transport))
|
||||
#expect(app.connectionHistory.entries(for: host.id).map(\.kind) == [.terminal(transport)])
|
||||
if host.sshEnabled {
|
||||
_ = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh))
|
||||
#expect(app.workspaces.last?.descriptor.kind == .ssh)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func switchingProtocolsRetainsSearchAndOnlyCompatibleSelections() {
|
||||
let ssh = Host(name: "Alpha", host: "ssh.invalid")
|
||||
let telnet = Host(
|
||||
|
||||
+32
-22
@@ -35,13 +35,29 @@ nonisolated enum HostOpenTarget: Hashable, Sendable {
|
||||
case telnetSession
|
||||
case sftpChannel
|
||||
|
||||
func accessProtocol(for host: Host) -> HostTerminalProtocol {
|
||||
switch self {
|
||||
case .preferredTerminal: host.preferredTerminalProtocol ?? .ssh
|
||||
case .telnetSession: .telnet
|
||||
default: .ssh
|
||||
static func terminal(_ transport: HostTerminalProtocol) -> Self {
|
||||
switch transport {
|
||||
case .ssh: .sshSession
|
||||
case .mosh: .moshSession
|
||||
case .eternalTerminal: .eternalTerminalSession
|
||||
case .telnet: .telnetSession
|
||||
}
|
||||
}
|
||||
|
||||
func connectionKind(for host: Host) -> ConnectionHistoryEntry.Kind? {
|
||||
switch self {
|
||||
case .preferredTerminal: .preferredTerminal(for: host)
|
||||
case .sshSession: .ssh
|
||||
case .moshSession: .mosh
|
||||
case .eternalTerminalSession: .eternalTerminal
|
||||
case .telnetSession: .telnet
|
||||
case .sftpChannel: .sftp
|
||||
}
|
||||
}
|
||||
|
||||
func accessProtocol(for host: Host) -> HostTerminalProtocol {
|
||||
connectionKind(for: host) == .telnet ? .telnet : .ssh
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide state shared by every window: the saved hosts, and the one code path
|
||||
@@ -1077,6 +1093,7 @@ final class AppModel {
|
||||
as target: HostOpenTarget,
|
||||
in window: WindowModel
|
||||
) -> Workspace? {
|
||||
guard let kind = target.connectionKind(for: host) else { return nil }
|
||||
guard
|
||||
admitHostOpen(
|
||||
host, protocol: target.accessProtocol(for: host), in: window,
|
||||
@@ -1088,20 +1105,12 @@ final class AppModel {
|
||||
configureConnectionHistory(in: window)
|
||||
beginOpening(host, as: target)
|
||||
|
||||
switch target {
|
||||
case .preferredTerminal:
|
||||
switch host.preferredTerminalProtocol {
|
||||
case .ssh: return window.openSSHSession(host: host)
|
||||
case .mosh: return window.openSSHSession(host: host, transport: .mosh)
|
||||
case .eternalTerminal: return window.openSSHSession(host: host, transport: .eternalTerminal)
|
||||
case .telnet: return window.openTelnetSession(host: host)
|
||||
case nil: return window.openTelnetSession(host: host)
|
||||
}
|
||||
case .sshSession: return window.openSSHSession(host: host)
|
||||
case .moshSession: return window.openSSHSession(host: host, transport: .mosh)
|
||||
case .eternalTerminalSession: return window.openSSHSession(host: host, transport: .eternalTerminal)
|
||||
case .telnetSession: return window.openTelnetSession(host: host)
|
||||
case .sftpChannel: return window.openSFTPChannel(host: host)
|
||||
switch kind {
|
||||
case .ssh: return window.openSSHSession(host: host)
|
||||
case .mosh: return window.openSSHSession(host: host, transport: .mosh)
|
||||
case .eternalTerminal: return window.openSSHSession(host: host, transport: .eternalTerminal)
|
||||
case .telnet: return window.openTelnetSession(host: host)
|
||||
case .sftp: return window.openSFTPChannel(host: host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,7 +1119,7 @@ final class AppModel {
|
||||
if target.isSaved {
|
||||
return open(
|
||||
target.host,
|
||||
as: target.connectionProtocol == .ssh ? .sshSession : .telnetSession,
|
||||
as: .terminal(target.connectionProtocol),
|
||||
in: window)
|
||||
}
|
||||
for node in target.route.hosts {
|
||||
@@ -1303,7 +1312,8 @@ final class AppModel {
|
||||
layout: BatchLayout = .separateWorkspaces,
|
||||
in window: WindowModel
|
||||
) {
|
||||
if let pending = hosts.first(where: {
|
||||
let eligibleHosts = hosts.filter { target.connectionKind(for: $0) != nil }
|
||||
if let pending = eligibleHosts.first(where: {
|
||||
do {
|
||||
try self.hosts.requireHostAccess($0, protocol: target.accessProtocol(for: $0))
|
||||
return false
|
||||
@@ -1318,7 +1328,7 @@ final class AppModel {
|
||||
}
|
||||
return
|
||||
}
|
||||
let hosts = hosts.filter { admitHostOpen($0, protocol: target.accessProtocol(for: $0), in: window) }
|
||||
let hosts = eligibleHosts.filter { admitHostOpen($0, protocol: target.accessProtocol(for: $0), in: window) }
|
||||
guard !hosts.isEmpty else { return }
|
||||
configureConnectionHistory(in: window)
|
||||
switch layout {
|
||||
|
||||
@@ -192,37 +192,27 @@ final class AppCommandRegistry {
|
||||
|
||||
private func hostCommands(_ host: Host) -> [AppCommandDescriptor] {
|
||||
let keywords = [host.host] + host.supportedTerminalProtocols.map { host.displayAddress(for: $0) } + host.tags
|
||||
var protocols = host.supportedTerminalProtocols
|
||||
if let preferred = host.preferredTerminalProtocol {
|
||||
protocols.removeAll { $0 == preferred }
|
||||
protocols.insert(preferred, at: 0)
|
||||
}
|
||||
var commands = protocols.map { transport in
|
||||
var commands = host.preferredFirstTerminalProtocols.map { transport in
|
||||
let address = host.displayAddress(for: transport)
|
||||
let action: HostConnectAction
|
||||
let subtitle: LocalizedStringResource
|
||||
switch transport {
|
||||
case .ssh:
|
||||
action = .ssh
|
||||
subtitle = AppStrings.Connections.connectWithSsh(address)
|
||||
case .mosh:
|
||||
action = .mosh
|
||||
subtitle = AppStrings.Connections.connectWithMosh(address)
|
||||
case .eternalTerminal:
|
||||
action = .eternalTerminal
|
||||
subtitle = AppStrings.Connections.connectWithEternalTerminal(address)
|
||||
case .telnet:
|
||||
action = .telnet
|
||||
subtitle = AppStrings.Connections.connectWithTelnet(address)
|
||||
}
|
||||
return AppCommandDescriptor(
|
||||
id: "host.\(transport.rawValue).\(host.id.uuidString)",
|
||||
title: host.name,
|
||||
subtitle: String(localized: subtitle),
|
||||
symbol: action.symbol,
|
||||
symbol: transport.symbol,
|
||||
section: .hosts,
|
||||
keywords: keywords + [transport.rawValue, transport.displayName],
|
||||
target: .connectHost(host.id, action.target)
|
||||
target: .connectHost(host.id, .terminal(transport))
|
||||
)
|
||||
}
|
||||
if host.sshEnabled {
|
||||
|
||||
@@ -1071,20 +1071,13 @@ final class WindowModel {
|
||||
@discardableResult
|
||||
func openWorkspace(_ hosts: [Host], as target: HostOpenTarget) -> Workspace? {
|
||||
let panes: [Pane] = hosts.compactMap { host in
|
||||
switch target {
|
||||
case .preferredTerminal:
|
||||
switch host.preferredTerminalProtocol {
|
||||
case .ssh: .sshSession(host: host)
|
||||
case .mosh: .sshSession(host: host, transport: .mosh)
|
||||
case .eternalTerminal: .sshSession(host: host, transport: .eternalTerminal)
|
||||
case .telnet: .telnetSession(host: host)
|
||||
case nil: nil
|
||||
}
|
||||
case .sshSession: .sshSession(host: host)
|
||||
case .moshSession: .sshSession(host: host, transport: .mosh)
|
||||
case .eternalTerminalSession: .sshSession(host: host, transport: .eternalTerminal)
|
||||
case .telnetSession: .telnetSession(host: host)
|
||||
case .sftpChannel: .sftpChannel(host: host)
|
||||
guard let kind = target.connectionKind(for: host) else { return nil }
|
||||
switch kind {
|
||||
case .ssh: return .sshSession(host: host)
|
||||
case .mosh: return .sshSession(host: host, transport: .mosh)
|
||||
case .eternalTerminal: return .sshSession(host: host, transport: .eternalTerminal)
|
||||
case .telnet: return .telnetSession(host: host)
|
||||
case .sftp: return .sftpChannel(host: host)
|
||||
}
|
||||
}
|
||||
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
||||
@@ -1101,10 +1094,9 @@ final class WindowModel {
|
||||
/// submits the same command independently after login.
|
||||
@discardableResult
|
||||
func openSnippetWorkspace(hosts: [Host], command: String) -> Workspace? {
|
||||
let panes = hosts.filter(\.sshEnabled).map { host in
|
||||
Pane.sshSession(
|
||||
host: host, initialCommand: command,
|
||||
transport: host.terminalTransports.preferred(isTailscaleManaged: host.isTailscaleManaged))
|
||||
let panes = hosts.compactMap { host -> Pane? in
|
||||
guard host.sshEnabled, let transport = host.preferredTerminalProtocol else { return nil }
|
||||
return .sshSession(host: host, initialCommand: command, transport: transport)
|
||||
}
|
||||
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
||||
|
||||
|
||||
@@ -54,6 +54,23 @@ struct HostAdmissionTests {
|
||||
#expect(app.proAccess.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test func unavailableDefaultDoesNotEnterAdmissionOrMarkTheHostConnecting() {
|
||||
let host = Host(name: "Disabled", host: "disabled.invalid", sshEnabled: false)
|
||||
let app = AppModel(
|
||||
hosts: HostLibraryModel(hosts: [host]), proAccess: .testing(status: .verificationFailed))
|
||||
let window = WindowModel()
|
||||
defer { app.shutdownForTermination() }
|
||||
|
||||
#expect(app.open(host, as: .preferredTerminal, in: window) == nil)
|
||||
for layout in [BatchLayout.separateWorkspaces, .oneWorkspace] {
|
||||
app.open([host], as: .preferredTerminal, layout: layout, in: window)
|
||||
}
|
||||
#expect(window.workspaces.isEmpty)
|
||||
#expect(window.hostAccessError == nil)
|
||||
#expect(!app.proAccess.isPaywallPresented)
|
||||
#expect(app.hostLibrary.status(of: host.id) == .disconnected)
|
||||
}
|
||||
|
||||
@Test func loadingBatchWaitsBeforeOpeningAnyTelnetPanes() async throws {
|
||||
let hosts = fixtures()
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .loading))
|
||||
|
||||
@@ -120,6 +120,30 @@ struct QuickConnectParserTests {
|
||||
|
||||
@Suite @MainActor
|
||||
struct QuickConnectRouteTests {
|
||||
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
|
||||
func explicitSSHCommandDoesNotUseTheSavedHostsDefault(transport: HostTerminalProtocol) throws {
|
||||
var host = Host(name: "prod", host: "server.invalid")
|
||||
host.terminalTransports.moshEnabled = true
|
||||
host.terminalTransports.eternalTerminalEnabled = true
|
||||
host.terminalTransports.defaultTransport = transport
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
let app = AppModel(hosts: library)
|
||||
let window = WindowModel()
|
||||
defer {
|
||||
window.closeAll()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
|
||||
let preferred = try #require(app.open(host, as: .preferredTerminal, in: window))
|
||||
#expect(preferred.focusedPane.sshSession?.transport == transport)
|
||||
let target = try resolve("ssh prod", in: library)
|
||||
#expect(target.isSaved)
|
||||
let explicit = try #require(app.openQuickConnect(target, in: window))
|
||||
#expect(explicit.focusedPane.sshSession?.transport == .ssh)
|
||||
let files = try #require(app.open(host, as: .sftpChannel, in: window))
|
||||
#expect(files.focusedPane.sftpChannel != nil)
|
||||
}
|
||||
|
||||
@Test func aliasesReuseMetadataAndOverriddenUserDoesNotInheritCredentials() throws {
|
||||
let originalIdentity = Identity(name: "Production", username: "root", credentialRef: "original")
|
||||
let original = Host(name: "prod", host: "server.invalid", identityID: originalIdentity.id)
|
||||
|
||||
@@ -181,6 +181,46 @@ struct QuickOpenTests {
|
||||
#expect(window.workspaces[0].panes.map(\.hostID) == [ssh.id, telnet.id])
|
||||
}
|
||||
|
||||
@Test(arguments: [BatchLayout.separateWorkspaces, .oneWorkspace])
|
||||
func defaultOpeningUsesEachHostsTransportAndSkipsUnavailableHosts(layout: BatchLayout) throws {
|
||||
let transports = HostTerminalProtocol.allCases
|
||||
let hosts = transports.map { transport in
|
||||
var host = Host(name: transport.rawValue, host: "server.invalid", telnetEnabled: true)
|
||||
host.sshEnabled = transport != .telnet
|
||||
host.terminalTransports.moshEnabled = true
|
||||
host.terminalTransports.eternalTerminalEnabled = true
|
||||
if transport != .telnet { host.terminalTransports.defaultTransport = transport }
|
||||
return host
|
||||
}
|
||||
let disabled = Host(name: "Disabled", host: "disabled.invalid", sshEnabled: false)
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts + [disabled]))
|
||||
let window = WindowModel()
|
||||
defer {
|
||||
window.closeAll()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
|
||||
for (host, transport) in zip(hosts, transports) {
|
||||
let workspace = try #require(app.open(host, as: .preferredTerminal, in: window))
|
||||
if transport == .telnet {
|
||||
#expect(workspace.focusedPane.telnetSession != nil)
|
||||
} else {
|
||||
#expect(workspace.focusedPane.sshSession?.transport == transport)
|
||||
}
|
||||
}
|
||||
window.closeAll()
|
||||
#expect(app.open(disabled, as: .preferredTerminal, in: window) == nil)
|
||||
app.open([disabled] + hosts, as: .preferredTerminal, layout: layout, in: window)
|
||||
|
||||
let panes = window.workspaces.flatMap(\.panes)
|
||||
#expect(panes.map(\.hostID) == hosts.map(\.id))
|
||||
#expect(panes.compactMap { $0.sshSession?.transport } == [.ssh, .mosh, .eternalTerminal])
|
||||
#expect(panes.last?.telnetSession != nil)
|
||||
#expect(window.workspaces.count == (layout == .oneWorkspace ? 1 : hosts.count))
|
||||
#expect(window.selectedWorkspaceID == window.workspaces.first?.id)
|
||||
#expect(app.hostLibrary.status(of: disabled.id) == .disconnected)
|
||||
}
|
||||
|
||||
@Test(arguments: [HostTerminalProtocol.ssh, .mosh, .eternalTerminal])
|
||||
func quickOpenPrioritizesAndOpensTheDefaultTransport(transport: HostTerminalProtocol) throws {
|
||||
var host = Host(name: "Production", host: "server.invalid")
|
||||
|
||||
Reference in new issue
Block a user