From 8b6325db7be87616ce2bd54ac55fa50655a2a237 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 18 Oct 2026 13:20:49 +0800 Subject: [PATCH] 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 --- FrameworkTests/TerminalTransportTests.swift | 26 +++++++++ .../Connections/ConnectionHistoryEntry.swift | 4 ++ Frameworks/TermindDomain/Hosts/Host.swift | 5 ++ TFI/App/MobileSceneModel.swift | 4 +- TFI/App/MobileWorkspaceSessionModel.swift | 8 ++- .../MobileConnectionProtocol.swift | 2 +- .../Workspaces/MobileConnectionHistory.swift | 8 +-- TFITests/MobileBatchStartupTests.swift | 29 ++++++++++ TFM/App/AppModel.swift | 54 +++++++++++-------- .../Commands/AppCommandRegistry.swift | 16 ++---- TFM/Features/Workspaces/WindowModel.swift | 28 ++++------ TFMTests/HostAdmissionTests.swift | 17 ++++++ TFMTests/QuickConnectTests.swift | 24 +++++++++ TFMTests/QuickOpenTests.swift | 40 ++++++++++++++ 14 files changed, 197 insertions(+), 68 deletions(-) diff --git a/FrameworkTests/TerminalTransportTests.swift b/FrameworkTests/TerminalTransportTests.swift index 2e32e375..7988886d 100644 --- a/FrameworkTests/TerminalTransportTests.swift +++ b/FrameworkTests/TerminalTransportTests.swift @@ -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)) diff --git a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift index 33795f6d..c392678d 100644 --- a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift +++ b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift @@ -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") diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index 35ed0b36..fef5f32a 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -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 diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index f95f5a90..a43f32da 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -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) diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index e6c2078d..501ed62c 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -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) diff --git a/TFI/Application/FeatureModels/MobileConnectionProtocol.swift b/TFI/Application/FeatureModels/MobileConnectionProtocol.swift index 001eb7ef..6eea11bd 100644 --- a/TFI/Application/FeatureModels/MobileConnectionProtocol.swift +++ b/TFI/Application/FeatureModels/MobileConnectionProtocol.swift @@ -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 } } diff --git a/TFI/Application/Workspaces/MobileConnectionHistory.swift b/TFI/Application/Workspaces/MobileConnectionHistory.swift index f95527d4..773f76a1 100644 --- a/TFI/Application/Workspaces/MobileConnectionHistory.swift +++ b/TFI/Application/Workspaces/MobileConnectionHistory.swift @@ -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( diff --git a/TFITests/MobileBatchStartupTests.swift b/TFITests/MobileBatchStartupTests.swift index fe3b39d0..af00ee7d 100644 --- a/TFITests/MobileBatchStartupTests.swift +++ b/TFITests/MobileBatchStartupTests.swift @@ -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( diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 2776557c..eded27a6 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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 { diff --git a/TFM/Application/Commands/AppCommandRegistry.swift b/TFM/Application/Commands/AppCommandRegistry.swift index 94595d5e..349c9583 100644 --- a/TFM/Application/Commands/AppCommandRegistry.swift +++ b/TFM/Application/Commands/AppCommandRegistry.swift @@ -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 { diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 65acf50c..22876e30 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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 } diff --git a/TFMTests/HostAdmissionTests.swift b/TFMTests/HostAdmissionTests.swift index c0a87982..b7b6e64f 100644 --- a/TFMTests/HostAdmissionTests.swift +++ b/TFMTests/HostAdmissionTests.swift @@ -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)) diff --git a/TFMTests/QuickConnectTests.swift b/TFMTests/QuickConnectTests.swift index dbdba087..71483e7b 100644 --- a/TFMTests/QuickConnectTests.swift +++ b/TFMTests/QuickConnectTests.swift @@ -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) diff --git a/TFMTests/QuickOpenTests.swift b/TFMTests/QuickOpenTests.swift index ec72b0e0..9a72a1df 100644 --- a/TFMTests/QuickOpenTests.swift +++ b/TFMTests/QuickOpenTests.swift @@ -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")