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:
Kaede AkinoandClaude Code committed 2026-10-18 13:20:49 +08:00
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
+2 -2
View File
@@ -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)
+3 -5
View File
@@ -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(
+29
View File
@@ -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
View File
@@ -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 {
+10 -18
View File
@@ -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 }
+17
View File
@@ -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))
+24
View File
@@ -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)
+40
View File
@@ -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")