diff --git a/FrameworkTests/HistorySyncTests.swift b/FrameworkTests/HistorySyncTests.swift index df82ee0b..97e52419 100644 --- a/FrameworkTests/HistorySyncTests.swift +++ b/FrameworkTests/HistorySyncTests.swift @@ -27,7 +27,7 @@ struct HistorySyncTests { #expect(device["payload"] == nil) #expect(try HistoryRecordSyncCoordinator.envelope(from: device) == fixture.device) event["endedAt"] = NSDate() - try HistoryCloudRecordV1.write(fixture.event, into: event) + try HistoryCloudRecordV2.write(fixture.event, into: event) #expect(event["endedAt"] == nil) event["connectionKind"] = "future-protocol" as NSString #expect(throws: (any Error).self) { try HistoryRecordSyncCoordinator.envelope(from: event) } diff --git a/FrameworkTests/SessionPersistenceTests.swift b/FrameworkTests/SessionPersistenceTests.swift index 30792cea..b6f69d80 100644 --- a/FrameworkTests/SessionPersistenceTests.swift +++ b/FrameworkTests/SessionPersistenceTests.swift @@ -3,13 +3,56 @@ import SwiftData import TermindApplication import TermindDomain import TermindPersistence -import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host +@testable import TermindRemoteInfrastructure + @Suite @MainActor struct SessionPersistenceTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func resumableTransportAttachesTmuxAndRestoresWithoutReplayingStartup(_ transport: HostTerminalProtocol) + async throws + { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + let hostID = UUID() + let preparation: SSHPTYChannel.StartupPreparation = { execute in + try await coordinator.prepare( + preferences: .init(enabled: true, backend: backend.id, onConnect: .createManaged), + registry: .init([backend.id: { _ in backend }]), execute: execute, + hostID: hostID, endpoint: "server:22", username: "user", startupCommand: "uptime", + requiresNewSession: false, shellIntegration: transport != .mosh) + } + let execute: RemoteSessionCommandExecutor = { _ in + Issue.record("The fake backend should not need a remote command") + throw CancellationError() + } + let startup = try await ResumableTerminalStartup.prepare( + restoring: false, preparation: preparation, execute: execute) + #expect(coordinator.attachment != nil) + #expect(await backend.commands == ["uptime"]) + if transport == .mosh { + #expect(try startup.moshArguments == " -- /bin/sh -c 'attach-test'") + } else { + #expect(try startup.eternalInput == Data(" exec /bin/sh -c 'attach-test'\r".utf8)) + } + let originalAttachment = coordinator.attachment + let resumed = try await ResumableTerminalStartup.prepare( + restoring: true, preparation: preparation, execute: execute) + #expect(resumed.plan == nil) + #expect(try resumed.moshArguments.isEmpty) + #expect(try resumed.eternalInput == nil) + #expect(coordinator.attachment == originalAttachment) + #expect(await backend.probeCount == 1) + #expect(await backend.commands == ["uptime"]) + + _ = try await ResumableTerminalStartup.prepare(restoring: false, preparation: preparation, execute: execute) + #expect(await backend.commands == ["uptime"]) + #expect(coordinator.attachment == originalAttachment) + } + @Test func newHostsDisablePersistenceAndAskEveryTimeWhenEnabled() { let host = Host(name: "New", host: "example.invalid") let tailscale = TailscaleHostPreferences() diff --git a/FrameworkTests/TerminalTCPBridgeTests.swift b/FrameworkTests/TerminalTCPBridgeTests.swift new file mode 100644 index 00000000..6400a6dd --- /dev/null +++ b/FrameworkTests/TerminalTCPBridgeTests.swift @@ -0,0 +1,137 @@ +import Darwin +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct TerminalTCPBridgeTests { + @Test func boundedBridgePreservesBytesAndTheFinalResponseAfterHalfClose() async throws { + var pair: [Int32] = [-1, -1] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + let peer = pair[1] + defer { Darwin.close(peer) } + configure(peer) + let route = try await routed(pair[0]) + let bridge = try TerminalTCPBridge(open: { throw CancellationError() }, initial: route) + let client = try connect(to: bridge.port) + defer { Darwin.close(client) } + let payload = Data((0..<131_072).map { UInt8($0 % 251) }) + async let received = read(peer, count: payload.count) + try await write(client, payload) + #expect(try await received == payload) + #expect(Darwin.shutdown(client, SHUT_WR) == 0) + let response = Data("final response after request EOF".utf8) + try await write(peer, response) + #expect(Darwin.shutdown(peer, SHUT_WR) == 0) + #expect(try await read(client, count: response.count) == response) + await bridge.close() + #expect(fcntl(route.socket, F_GETFD) == -1) + } + + @Test func replacementConnectionGetsANewRouteAndCloseReleasesBoth() async throws { + var first: [Int32] = [-1, -1] + var second: [Int32] = [-1, -1] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &first) == 0) + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &second) == 0) + defer { + Darwin.close(first[1]) + Darwin.close(second[1]) + } + configure(first[1]) + configure(second[1]) + let initial = try await routed(first[0]) + let replacement = try await routed(second[0]) + let bridge = try TerminalTCPBridge(open: { replacement }, initial: initial) + let oldClient = try connect(to: bridge.port) + try await write(oldClient, Data([1])) + #expect(try await read(first[1], count: 1) == Data([1])) + Darwin.close(oldClient) + let newClient = try connect(to: bridge.port) + defer { Darwin.close(newClient) } + try await write(newClient, Data([2])) + #expect(try await read(second[1], count: 1) == Data([2])) + await bridge.close() + #expect(fcntl(initial.socket, F_GETFD) == -1) + #expect(fcntl(replacement.socket, F_GETFD) == -1) + } + + private nonisolated func routed(_ socket: Int32) async throws -> SSHRoutedSocket { + try await SSHRoutedSocket.open( + host: "routed.invalid", port: 2022, proxy: nil, jumps: [], timeout: 2, + authorizeHostKey: nil, network: Network(socket: socket)) + } + + private nonisolated struct Network: SSHNetworkDialing { + let socket: Int32 + func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { socket } + func hostKeys(stableNodeID: String) async throws -> Set { [] } + } + + private nonisolated func connect(to port: UInt16) throws -> Int32 { + let socket = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard socket >= 0 else { throw POSIXError(.ENOBUFS) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = port.bigEndian + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(socket, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + Darwin.close(socket) + throw POSIXError(.ECONNREFUSED) + } + configure(socket) + return socket + } + + private nonisolated func configure(_ socket: Int32) { + _ = fcntl(socket, F_SETFL, fcntl(socket, F_GETFL) | O_NONBLOCK) + var one: Int32 = 1 + _ = setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + } + + private nonisolated func read(_ socket: Int32, count: Int) async throws -> Data { + var result = Data() + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while result.count < count { + var bytes = [UInt8](repeating: 0, count: min(4096, count - result.count)) + let received = Darwin.recv(socket, &bytes, bytes.count, 0) + if received > 0 { + result.append(contentsOf: bytes.prefix(received)) + } else if received == 0 { + throw POSIXError(.ECONNRESET) + } else { + guard errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR else { + throw POSIXError(.EIO) + } + guard ContinuousClock.now < deadline else { throw POSIXError(.ETIMEDOUT) } + try await Task.sleep(for: .milliseconds(5)) + } + } + return result + } + + private nonisolated func write(_ socket: Int32, _ data: Data) async throws { + var remaining = data + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !remaining.isEmpty { + let sent = remaining.withUnsafeBytes { Darwin.send(socket, $0.baseAddress, $0.count, 0) } + if sent > 0 { + remaining.removeFirst(sent) + } else { + guard errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR else { + throw POSIXError(.EIO) + } + guard ContinuousClock.now < deadline else { throw POSIXError(.ETIMEDOUT) } + try await Task.sleep(for: .milliseconds(5)) + } + } + } +} diff --git a/FrameworkTests/TerminalTransportTests.swift b/FrameworkTests/TerminalTransportTests.swift new file mode 100644 index 00000000..7d6fd38b --- /dev/null +++ b/FrameworkTests/TerminalTransportTests.swift @@ -0,0 +1,276 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import TermindRemoteInfrastructure +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct TerminalTransportTests { + @Test func defaultsKeepSSHAndDisablingTheDefaultDoesNotSelectAnotherEnabledTransport() { + var host = Host(name: "Server", host: "example.invalid") + #expect(host.preferredTerminalProtocol == .ssh) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.telnetEnabled = true + #expect(host.supportedTerminalProtocols == [.ssh, .mosh, .eternalTerminal, .telnet]) + host.terminalTransports.defaultTransport = .mosh + #expect(host.preferredTerminalProtocol == .mosh) + host.terminalTransports.moshEnabled = false + host.terminalTransports.normalizeDefault(isTailscaleManaged: false) + #expect(host.terminalTransports.defaultTransport == .ssh) + host.sshEnabled = false + #expect(host.supportedTerminalProtocols == [.telnet]) + #expect(host.preferredTerminalProtocol == .telnet) + #expect(host.terminalTransports.eternalTerminalEnabled) + } + + @Test func rangeValidationCannotInjectBootstrapArguments() { + for valid in ["", "1", "65535", "60001-60999", "1-65535"] { + #expect(HostTerminalTransports.isValidMoshPortRange(valid)) + } + for invalid in ["0", "65536", "-1", "2-1", "60001-", "1-2-3", "1;id", " 22", "22\n", "60001"] { + #expect(!HostTerminalTransports.isValidMoshPortRange(invalid)) + } + var options = HostTerminalTransports() + options.eternalTerminalEnabled = true + options.eternalTerminalPort = 65_536 + #expect(!options.isValid) + options.eternalTerminalPort = 2022 + #expect(options.isValid) + } + + @Test func transportNamesAndSymbolsAreDistinctAndOrderedBeforeTelnet() { + #expect(HostTerminalProtocol.allCases == [.ssh, .mosh, .eternalTerminal, .telnet]) + #expect(HostTerminalProtocol.mosh.displayName == "Mosh") + #expect(HostTerminalProtocol.eternalTerminal.displayName == "Eternal Terminal") + #expect(Set(HostTerminalProtocol.allCases.map(\.symbol)).count == 4) + #expect(ConnectionHistoryEntry.Kind.mosh.symbol == HostTerminalProtocol.mosh.symbol) + #expect(ConnectionHistoryEntry.Kind.eternalTerminal.symbol == HostTerminalProtocol.eternalTerminal.symbol) + } + + @Test func legacyHostDecodesWithSSHAndTransportPreferencesSurviveReopening() throws { + var host = Host(name: "Host", host: "server.invalid", iconOverrideID: "macos") + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(host)) as? [String: Any]) + json.removeValue(forKey: "terminalTransports") + let legacyHost = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(legacyHost.terminalTransports == .init()) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("store") + do { + let schema = Schema(versionedSchema: TermindSchemaV54.self) + let legacy = try ModelContainer( + for: schema, + configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)) + legacy.mainContext.insert(TermindSchemaV54HostModels.PersistentHost(host)) + try legacy.mainContext.save() + } + do { + let container = try TermindPersistence.makeContainer(url: url) + let store = HostPersistence(context: container.mainContext) + #expect(try store.load().hosts == [host]) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.terminalTransports.defaultTransport = .eternalTerminal + host.terminalTransports.moshUDPPortRange = "60001-60999" + host.terminalTransports.eternalTerminalPort = 2222 + _ = try store.save(.init(hosts: [host])) + } + let reopened = try TermindPersistence.makeContainer(url: url) + #expect(try HostPersistence(context: reopened.mainContext).load().hosts == [host]) + } + + @Test func wireVersionsProtectTransportChoicesFromOlderWriters() throws { + var host = Host(name: "Host", host: "server.invalid") + host.terminalTransports.eternalTerminalEnabled = true + host.terminalTransports.defaultTransport = .eternalTerminal + let payload = VaultRecordPayload.host(host) + let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) + for version in [1, 2, 4, 5] { + var old = envelope + old.formatVersion = version + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) } + } + let template = WorkspaceTemplate(name: "ET", layout: .pane(.eternalTerminal(hostID: host.id))) + let templatePayload = VaultRecordPayload.workspaceTemplate(template) + let templateEnvelope = VaultEnvelope( + key: templatePayload.key, version: .baseline(writerID: UUID()), payload: templatePayload) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(templateEnvelope)) == templateEnvelope) + var oldTemplate = templateEnvelope + oldTemplate.formatVersion = 1 + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(oldTemplate) } + } + + @Test func tailscaleKeepsETPreferencesWithoutEnablingMosh() throws { + var group = HostGroup(name: "Tailnet") + group.tailscale = TailscaleGroupConfiguration( + network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net")) + let peer = TailscalePeer( + stableID: "peer", name: "Server", dnsName: "server.test.ts.net", online: true, + sshHostKeys: ["ssh-ed25519 test"]) + let library = HostLibraryModel(groups: [group]) + library.replaceTailscalePeers([peer], groupID: group.id) + var host = try #require(library.hosts.first) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.terminalTransports.defaultTransport = .eternalTerminal + #expect(library.saveTailscaleHost(host)) + library.replaceTailscalePeers([peer], groupID: group.id) + let projected = try #require(library.host(host.id)) + #expect(projected.supportedTerminalProtocols == [.ssh, .eternalTerminal]) + #expect(projected.preferredTerminalProtocol == .eternalTerminal) + let payload = VaultRecordPayload.hostGroup(try #require(library.group(group.id))) + let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) + } + + @Test func checkpointsAreEncryptedConsumedOnceAndCannotResurrectAfterClose() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = TerminalRecoveryStore(directory: directory, testingKey: Data(repeating: 7, count: 32)) + var host = Host(name: "Host", host: "server.invalid") + host.terminalTransports.eternalTerminalEnabled = true + let record = TerminalRecoveryRecord(id: UUID(), host: host, username: "user", transport: .eternalTerminal) + let secret = Data("checkpoint-secret-passkey-and-counters".utf8) + let checkpoint = TerminalTransportCheckpoint(transport: .eternalTerminal, payload: secret) + let lease = await store.register(record.id) + try await store.save(record, checkpoint: checkpoint, lease: lease) + let file = try #require( + FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ).first) + #expect(try Data(contentsOf: file).range(of: secret) == nil) + var changed = host + changed.host = "another.invalid" + await #expect(throws: (any Error).self) { try await store.consume(record.id, host: changed) } + #expect(try await store.records().count == 1) + #expect(try await store.consume(record.id, host: host).payload == secret) + #expect(try await store.records().isEmpty) + await #expect(throws: (any Error).self) { try await store.consume(record.id, host: host) } + try await store.discard(record.id) + await #expect(throws: (any Error).self) { + try await store.save(record, checkpoint: checkpoint, lease: lease) + } + #expect(try await store.records().isEmpty) + } + + @Test func routedResumeAuthenticatesTheJumpAndProxyButNeverTheTarget() async throws { + let target = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Target", host: "target.invalid", port: 22, + authentication: .init(identityID: UUID(), username: "target-user"), + expectedHostKeys: [], proxy: nil) + let jump = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Jump", host: "jump.invalid", port: 2222, + authentication: .init(username: "jump-user", hasEphemeralSecret: true), expectedHostKeys: [], + proxy: .init(kind: .socks5, hostname: "proxy.invalid", port: 1080, username: "proxy-user", identityID: nil)) + let prompts = RoutePrompts() + let request = try await HostLibraryModel().resumableRouteRequest( + from: .init(target: target, jumps: [jump]), authorizeHostKey: nil, + keyboardInteractive: { challenge in + await prompts.record(challenge) + return ["one-shot"] + }, + securityKeySigning: nil, hostKeyConstraints: [:]) + let challenges = await prompts.challenges + #expect(challenges.map(\.hostname) == ["jump.invalid", "proxy.invalid"]) + #expect(challenges.map(\.username) == ["jump-user", "proxy-user"]) + #expect(challenges.allSatisfy { $0.prompts.count == 1 && !$0.prompts[0].echo }) + #expect(request.configuration.host == "target.invalid") + #expect(request.configuration.credentials.isEmpty) + let hop = try #require(request.configuration.jumps.first) + guard case .password(let password) = try #require(hop.credentials.first) else { + Issue.record("The jump should receive its one-shot password") + return + } + #expect(password.matches("one-shot")) + #expect(hop.proxy?.password?.matches("one-shot") == true) + request.configuration.clearSecrets() + #expect(password.isConsumed) + #expect(hop.proxy?.password?.isConsumed == true) + } + + @Test func cancelledRouteAuthenticationDoesNotCreateATargetConnection() async throws { + let node = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Jump", host: "jump.invalid", port: 22, + authentication: .init(username: "user", hasEphemeralSecret: true), + expectedHostKeys: [], proxy: nil) + await #expect(throws: CancellationError.self) { + try await HostLibraryModel().resumableRouteRequest( + from: .init(target: node, jumps: [node]), authorizeHostKey: nil, + keyboardInteractive: { _ in throw CancellationError() }, securityKeySigning: nil, + hostKeyConstraints: [:]) + } + } + + @Test func explicitTmuxControlAuthenticatesTheTargetAndRetainsItsTrust() async throws { + let key = SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:target") + let target = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Target", host: "target.invalid", port: 22, + authentication: .init(username: "user", hasEphemeralSecret: true), + expectedHostKeys: [key], proxy: nil) + let prompts = RoutePrompts() + let request = try await HostLibraryModel().resumableRouteRequest( + from: .init(target: target, jumps: []), authorizeHostKey: nil, + keyboardInteractive: { challenge in + await prompts.record(challenge) + return ["control-secret"] + }, + securityKeySigning: nil, hostKeyConstraints: [:], authenticatingTarget: true) + #expect(await prompts.challenges.map(\.hostname) == ["target.invalid"]) + #expect(request.configuration.jumps.isEmpty) + #expect(request.configuration.expectedHostKeys == [key]) + guard case .password(let password) = try #require(request.configuration.credentials.first) else { + Issue.record("Explicit tmux control needs target authentication") + return + } + #expect(password.matches("control-secret")) + request.configuration.clearSecrets() + #expect(password.isConsumed) + } + + @Test func restoredMoshControlRebuildsItsJumpRouteWithoutReadingSecrets() async throws { + let jump = Host(name: "Jump", host: "jump.invalid") + var target = Host(name: "Target", host: "target.invalid") + target.jumpHostID = jump.id + target.proxy = .init(kind: .socks5, hostname: "proxy.invalid", port: 1080) + let library = HostLibraryModel(hosts: [jump, target]) + let snapshot = try library.resumableControlSnapshot(for: target, username: "target-user") + #expect(snapshot.jumps.map(\.id) == [jump.id]) + #expect(snapshot.target.proxy?.hostname == "proxy.invalid") + #expect(snapshot.target.authentication.username == "target-user") + let prompts = RoutePrompts() + let request = try await library.resumableRouteRequest( + from: snapshot, authorizeHostKey: nil, + keyboardInteractive: { challenge in + await prompts.record(challenge) + return [challenge.prompts.first?.echo == true ? "jump-user" : "secret"] + }, + securityKeySigning: nil, hostKeyConstraints: [:], authenticatingTarget: true) + defer { request.configuration.clearSecrets() } + #expect(request.configuration.jumps.first?.username == "jump-user") + #expect(request.configuration.username == "target-user") + #expect(await prompts.challenges.map(\.hostname) == ["jump.invalid", "jump.invalid", "target.invalid"]) + } + + @Test func partialAuthenticatedShellEventsSurviveCheckpointEncoding() throws { + var filter = RemoteCommandEventFilter(token: "checkpoint") + let prefix = Data("\u{1b}]777;TermindCommand=checkpoint;echo%20".utf8) + #expect(filter.receive(prefix).events.isEmpty) + var restored = try JSONDecoder().decode( + RemoteCommandEventFilter.self, from: JSONEncoder().encode(filter)) + let update = restored.receive(Data("hello\u{7}visible".utf8)) + #expect(update.events == [.commandStarted("echo hello")]) + #expect(update.visibleOutput == Data("visible".utf8)) + } + + private actor RoutePrompts { + private(set) var challenges: [SSHKeyboardInteractiveChallenge] = [] + func record(_ challenge: SSHKeyboardInteractiveChallenge) { challenges.append(challenge) } + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7771b912..41a01041 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -14731,6 +14731,53 @@ } } }, + "Close Session" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzung schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close Session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer la session" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションを閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 닫기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉連線階段" + } + } + } + }, "Close Termind" : { "extractionState" : "manual", "localizations" : { @@ -27895,6 +27942,53 @@ } } }, + "Eternal Terminal" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal" + } + } + } + }, "Even" : { "extractionState" : "manual", "localizations" : { @@ -39857,6 +39951,53 @@ } } }, + "Mosh" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh" + } + } + } + }, "Mouse" : { "extractionState" : "manual", "localizations" : { @@ -50247,6 +50388,53 @@ } } }, + "Port Range" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portbereich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Port Range" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plage de ports" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポート範囲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "포트 범위" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "端口范围" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連接埠範圍" + } + } + } + }, "Prefer Built-in Web Search" : { "extractionState" : "manual", "localizations" : { @@ -72190,6 +72378,53 @@ } } }, + "The saved terminal session cannot be restored." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die gespeicherte Terminalsitzung kann nicht wiederhergestellt werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The saved terminal session cannot be restored." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de restaurer la session de terminal enregistrée." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存されたターミナルセッションを復元できません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 터미널 세션을 복원할 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法恢复已保存的终端会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法恢復已儲存的終端機連線階段。" + } + } + } + }, "The selected Identity cannot be updated on this device." : { "extractionState" : "manual", "localizations" : { @@ -73304,6 +73539,147 @@ } } }, + "The terminal server did not respond. Check the transport port and firewall." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Terminalserver antwortet nicht. Prüfen Sie den Transportport und die Firewall." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The terminal server did not respond. Check the transport port and firewall." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le serveur de terminal ne répond pas. Vérifiez le port de transport et le pare-feu." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナルサーバーが応答しません。トランスポートのポートとファイアウォールを確認してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 서버가 응답하지 않습니다. 전송 포트와 방화벽을 확인하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端服务器未响应。请检查传输端口和防火墙。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端機伺服器未回應。請檢查傳輸連接埠和防火牆。" + } + } + } + }, + "The terminal session cannot continue. Open a new session to connect again." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Terminalsitzung kann nicht fortgesetzt werden. Öffnen Sie zum erneuten Verbinden eine neue Sitzung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The terminal session cannot continue. Open a new session to connect again." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session de terminal ne peut pas continuer. Ouvrez une nouvelle session pour vous reconnecter." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナルセッションを続行できません。新しいセッションを開いて接続し直してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 세션을 계속할 수 없습니다. 새 세션을 열어 다시 연결하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端会话无法继续。请打开新会话重新连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端機連線階段無法繼續。請開啟新的連線階段以重新連線。" + } + } + } + }, + "The terminal session is suspended." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Terminalsitzung ist angehalten." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The terminal session is suspended." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session de terminal est suspendue." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナルセッションは一時停止中です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 세션이 일시 중단되었습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端会话已挂起。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端機連線階段已暫停。" + } + } + } + }, "The transfer failed." : { "extractionState" : "manual", "localizations" : { @@ -75901,6 +76277,53 @@ } } }, + "This terminal transport is unavailable for this connection." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Terminaltransport ist für diese Verbindung nicht verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This terminal transport is unavailable for this connection." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce transport de terminal n’est pas disponible pour cette connexion." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この接続では、このターミナルトランスポートを利用できません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결에서는 이 터미널 전송 방식을 사용할 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接无法使用该终端传输方式。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線無法使用該終端機傳輸方式。" + } + } + } + }, "This theme is unavailable for this appearance. Using “%@” instead." : { "extractionState" : "manual", "localizations" : { @@ -77392,6 +77815,12 @@ "value" : "Transport" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transport" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -78606,6 +79035,53 @@ } } }, + "Unable to start the terminal server. Check that it is installed on the host." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Terminalserver kann nicht gestartet werden. Prüfen Sie, ob er auf dem Host installiert ist." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to start the terminal server. Check that it is installed on the host." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de démarrer le serveur de terminal. Vérifiez qu’il est installé sur l’hôte." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナルサーバーを起動できません。ホストにインストールされていることを確認してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 서버를 시작할 수 없습니다. 호스트에 설치되어 있는지 확인하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法启动终端服务器。请确认主机已安装相应程序。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法啟動終端機伺服器。請確認主機已安裝相應程式。" + } + } + } + }, "Unable to update preview: %@" : { "extractionState" : "manual", "localizations" : { @@ -87490,6 +87966,100 @@ } } }, + "with Eternal Terminal" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mit Eternal Terminal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "with Eternal Terminal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "via Eternal Terminal" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminalで" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal로" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Eternal Terminal" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Eternal Terminal" + } + } + } + }, + "with Mosh" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mit Mosh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "with Mosh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "via Mosh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moshで" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh로" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Mosh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Mosh" + } + } + } + }, "with SFTP" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index d46ce501..a7b74736 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -5,12 +5,23 @@ import struct TermindDomain.Host public nonisolated enum ConnectionPurpose: String, Hashable, Sendable { case sshSession + case moshSession + case eternalTerminalSession case telnetSession case sftpChannel = "sftpSession" case serialSession case metrics case portForwarding case keyExport + + public static func terminal(_ transport: HostTerminalProtocol) -> Self { + switch transport { + case .ssh: .sshSession + case .mosh: .moshSession + case .eternalTerminal: .eternalTerminalSession + case .telnet: .telnetSession + } + } } public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable { @@ -457,6 +468,8 @@ extension ConnectionPurpose { fileprivate var logName: String { switch self { case .sshSession: "SSH session" + case .moshSession: "Mosh session" + case .eternalTerminalSession: "Eternal Terminal session" case .telnetSession: "Telnet session" case .sftpChannel: "SFTP channel" case .serialSession: "serial session" diff --git a/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift b/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift new file mode 100644 index 00000000..51cfca2d --- /dev/null +++ b/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift @@ -0,0 +1,172 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +extension RemoteSessionHostStore { + /// A restored Mosh session has no SSH route in its protocol checkpoint. Rebuild only + /// metadata when the user explicitly requests a tmux control operation. + public func resumableControlSnapshot(for host: Host, username: String) throws -> SSHAdditionalConnectionSnapshot { + let preparation = try prepareSSHConnection(for: host, submissions: [:], proxySubmissions: [:]) + let nodes = preparation.nodes.map { node in + SSHAdditionalConnectionSnapshot.Node( + id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.sshPort, + authentication: .init( + identityID: node.identity?.id, fallbackKeyID: node.key?.id, + username: node.identity?.username ?? node.tailscaleUsername + ?? (node.host.id == host.id ? username : ""), + permitsNone: node.host.isTailscaleManaged || node.identity?.hasSecret == false, + hasEphemeralSecret: node.identity == nil && !node.host.isTailscaleManaged), + expectedHostKeys: node.trustedKeys, + proxy: node.host.proxy.map { + .init( + kind: $0.kind, hostname: $0.hostname, port: $0.port, username: $0.username, + identityID: $0.identityID) + }, charset: node.host.charset) + } + guard let target = nodes.last else { throw SSHConnectionRoute.invalidRouteError } + var snapshot = SSHAdditionalConnectionSnapshot(target: target, jumps: Array(nodes.dropLast())) + snapshot.tailscaleNodeID = host.tailscale?.stableNodeID + snapshot.requiresTailscale = preparation.nodes.contains { $0.network != nil } + return snapshot + } + + /// Reauthenticates the TCP route. Only explicit control operations authenticate the target as well. + /// Secrets live for this one opening; normal transport recovery never needs target credentials. + public func resumableRouteRequest( + from snapshot: SSHAdditionalConnectionSnapshot, + authorizeHostKey: SSHHostKeyAuthorizer?, + keyboardInteractive: SSHKeyboardInteractiveHandler?, + securityKeySigning: SecurityKeySigningHandler?, + hostKeyConstraints: [Host.ID: SSHHostKeyConstraint], + authenticatingTarget: Bool = false + ) async throws -> SSHConnectionRequest { + let connectionID = UUID() + var resolvedCredentials: [SSHCredentials] = [] + var resolvedProxies: [SSHSecret] = [] + var completed = false + defer { + if !completed { + for credential in resolvedCredentials { credential.clearSecrets() } + for secret in resolvedProxies { secret.clear() } + } + } + + func prompt( + node: SSHAdditionalConnectionSnapshot.Node, label: String, username: String, + proxy: SSHAdditionalConnectionSnapshot.Proxy? = nil + ) async throws -> SSHSecret { + guard let keyboardInteractive else { throw SSHInteractionRequired() } + let answers = try await keyboardInteractive( + SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: node.id, + hostname: proxy?.hostname ?? node.host, port: proxy?.port ?? node.port, + username: username, round: 0, name: node.name, instruction: "", + prompts: [.init(text: label, echo: false)])) + try Task.checkCancellation() + guard answers.count == 1 else { throw CancellationError() } + return SSHSecret(answers[0]) + } + + func proxy(for node: SSHAdditionalConnectionSnapshot.Node) async throws -> SSHTransportProxy? { + guard let value = node.proxy else { return nil } + let identity = value.identityID.flatMap(self.identity) + if value.identityID != nil, identity == nil { throw TermindSSHError.missingCredential } + var password: SSHSecret? + if let identity { password = try connectionPasswordSecret(for: identity) } + if password == nil, identity != nil || !value.username.isEmpty { + password = try await prompt( + node: node, label: String(localized: "Password"), username: value.username, proxy: value) + } + if let password { resolvedProxies.append(password) } + return SSHTransportProxy( + kind: value.kind, hostname: value.hostname, port: value.port, + username: value.username, password: password, identityID: value.identityID, ownerHostID: node.id) + } + + var jumps: [SSHRouteHopConfiguration] = [] + for node in snapshot.jumps + (authenticatingTarget ? [snapshot.target] : []) { + try Task.checkCancellation() + let reference = node.authentication + let identity = reference.identityID.flatMap(self.identity) + if reference.identityID != nil, identity == nil { throw TermindSSHError.missingCredential } + var username = (identity?.username ?? reference.username).trimmingCharacters(in: .whitespacesAndNewlines) + if username.isEmpty, authenticatingTarget, let keyboardInteractive { + let answers = try await keyboardInteractive( + SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: node.id, hostname: node.host, port: node.port, + username: "", round: 0, name: node.name, instruction: "", + prompts: [.init(text: String(localized: "Username"), echo: true)])) + try Task.checkCancellation() + guard answers.count == 1 else { throw CancellationError() } + username = answers[0].trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + var credentials: [SSHCredentials] = [] + if let keyID = identity?.keyID ?? reference.fallbackKeyID { + guard let key = key(keyID), let stored = try connectionKeySecretCredential(for: key) else { + throw TermindSSHError.missingCredential + } + do { + let passphrase = + if key.hasPassphrase && !stored.hasSavedPassphrase { + try await prompt(node: node, label: String(localized: "Passphrase"), username: username) + } else { + nil as SSHSecret? + } + let credential = stored.sshCredential(for: key, passphraseOverride: passphrase) + credentials.append(credential) + resolvedCredentials.append(credential) + } catch { + stored.clearSecrets() + throw error + } + } + if let identity, identity.usesPassword, let password = try connectionPasswordSecret(for: identity) { + let credential = SSHCredentials.password(password) + credentials.append(credential) + resolvedCredentials.append(credential) + } + if credentials.isEmpty, + reference.hasEphemeralSecret || !reference.permitsNone || identity?.usesPassword == true + { + let credential = SSHCredentials.password( + try await prompt(node: node, label: String(localized: "Password"), username: username)) + credentials.append(credential) + resolvedCredentials.append(credential) + } + let known = Set( + knownHosts(for: Host(id: node.id, name: node.name, host: node.host, port: node.port)).map(\.identity)) + jumps.append( + SSHRouteHopConfiguration( + id: node.id, name: node.name, host: node.host, port: node.port, + username: username, credentials: credentials, + expectedHostKeys: known.isEmpty ? node.expectedHostKeys : known, + proxy: try await proxy(for: node), authenticationReference: reference)) + } + let target = authenticatingTarget ? jumps.removeLast() : nil + let targetProxy: SSHTransportProxy? + if let target { + targetProxy = target.proxy + } else { + targetProxy = try await proxy(for: snapshot.target) + } + var configuration = SSHConnectionConfiguration( + hostID: snapshot.target.id, host: snapshot.target.host, port: snapshot.target.port, + username: target?.username ?? snapshot.target.authentication.username, + credentials: target?.credentials ?? [], expectedHostKeys: target?.expectedHostKeys ?? [], + proxy: targetProxy, jumps: jumps, charset: snapshot.target.charset, + authenticationReference: snapshot.target.authentication) + configuration.tailscaleNodeID = snapshot.tailscaleNodeID + if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) { + configuration.network = try tailscaleNetwork(for: first) + } + if snapshot.requiresTailscale, configuration.network == nil { throw TailscaleConfigurationError.unavailable } + try Task.checkCancellation() + completed = true + return SSHConnectionRequest( + configuration: configuration, authorizeHostKey: authorizeHostKey, + keyboardInteractive: keyboardInteractive, securityKeySigning: securityKeySigning, + hostKeyConstraints: hostKeyConstraints) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift index 5ea3ae39..70d9b258 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift @@ -11,6 +11,8 @@ public struct ConnectionHistoryMenuRow: Identifiable { switch entry.kind { case .ssh, .sftp: host.sshEnabled case .telnet: host.telnetEnabled + case .mosh: host.isEnabled(.mosh) + case .eternalTerminal: host.isEnabled(.eternalTerminal) } } } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 0f7c6535..6cdf1463 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -2504,6 +2504,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { host.iconOverrideID = preferences.iconOverrideID host.sshCharset = preferences.charset host.sftpChannelInitialPath = preferences.sftpInitialPath + host.terminalTransports = preferences.terminalTransports host.sessionPersistence = preferences.sessionPersistence host.shellIntegrationMode = preferences.shellIntegrationMode host.startupSnippetID = preferences.startupSnippetID @@ -2533,6 +2534,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { preferences.iconOverrideID = host.iconOverrideID preferences.charset = host.sshCharset preferences.sftpInitialPath = host.sftpChannelInitialPath + preferences.terminalTransports = host.terminalTransports preferences.sessionPersistence = host.sessionPersistence preferences.shellIntegrationMode = host.shellIntegrationMode preferences.startupSnippetID = host.startupSnippetID diff --git a/Frameworks/TermindApplication/Terminal/ResumableTerminalSession.swift b/Frameworks/TermindApplication/Terminal/ResumableTerminalSession.swift new file mode 100644 index 00000000..e1767aeb --- /dev/null +++ b/Frameworks/TermindApplication/Terminal/ResumableTerminalSession.swift @@ -0,0 +1,28 @@ +import Foundation +import TermindDomain + +public nonisolated enum ResumableTerminalState: Equatable, Sendable { + case connecting, connected, recovering, suspended, ended, closed + case failed +} + +/// Opaque protocol state is sensitive: only the encrypted, device-local recovery store may persist it. +public nonisolated struct TerminalTransportCheckpoint: Codable, Sendable { + public var version = 1 + public let transport: HostTerminalProtocol + public let payload: Data + + public init(transport: HostTerminalProtocol, payload: Data) { + self.transport = transport + self.payload = payload + } +} + +public nonisolated protocol ResumableTerminalSession: Sendable { + func write(_ data: Data) async throws + func resize(columns: UInt16, rows: UInt16) async throws + func suspend() async throws -> TerminalTransportCheckpoint + func resume() async throws + func retryNow() async throws + func close() async +} diff --git a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift index 4c1b710d..33795f6d 100644 --- a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift +++ b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift @@ -9,20 +9,47 @@ public nonisolated struct ConnectionHistoryEntry: Identifiable, Codable, Hashabl /// and TFI does not expose Serial workspaces, so they never enter this timeline. public nonisolated enum Kind: String, Codable, CaseIterable, Hashable, Sendable { case ssh + case mosh + case eternalTerminal case telnet case sftp + public var terminalProtocol: HostTerminalProtocol? { + switch self { + case .ssh: .ssh + case .telnet: .telnet + case .mosh: .mosh + case .eternalTerminal: .eternalTerminal + case .sftp: nil + } + } + + public var isSSHTerminal: Bool { self == .ssh || self == .mosh || self == .eternalTerminal } + + public static func terminal(_ transport: HostTerminalProtocol) -> Self { + switch transport { + case .ssh: .ssh + case .telnet: .telnet + case .mosh: .mosh + case .eternalTerminal: .eternalTerminal + } + } + public var label: String { switch self { case .ssh: String(localized: "SSH") case .telnet: String(localized: "Telnet") case .sftp: String(localized: "SFTP") + case .mosh: "Mosh" + case .eternalTerminal: "Eternal Terminal" } } public var symbol: String { switch self { case .ssh: "terminal" + case .mosh: HostTerminalProtocol.mosh.symbol + case .eternalTerminal: HostTerminalProtocol.eternalTerminal.symbol case .telnet: "text.rectangle" case .sftp: "folder" } diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index bf689e68..52b40b6c 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -79,14 +79,29 @@ public nonisolated struct HostProxy: Codable, Hashable, Sendable { public nonisolated enum HostTerminalProtocol: String, Codable, CaseIterable, Identifiable, Sendable { case ssh + case mosh + case eternalTerminal case telnet public var id: Self { self } + public var isResumable: Bool { self == .mosh || self == .eternalTerminal } + + public var symbol: String { + switch self { + case .ssh: "apple.terminal" + case .mosh: "antenna.radiowaves.left.and.right" + case .eternalTerminal: "arrow.trianglehead.2.clockwise.rotate.90" + case .telnet: "text.rectangle" + } + } + public var displayName: String { switch self { case .ssh: String(localized: "SSH") case .telnet: String(localized: "Telnet") + case .mosh: "Mosh" + case .eternalTerminal: "Eternal Terminal" } } @@ -94,6 +109,8 @@ public nonisolated enum HostTerminalProtocol: String, Codable, CaseIterable, Ide switch self { case .ssh: 22 case .telnet: 23 + case .mosh: 22 + case .eternalTerminal: 2022 } } } @@ -131,6 +148,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { /// field accepts either. public var host: String public var sshEnabled = true + public var terminalTransports = HostTerminalTransports() public var sshPort: Int = 22 /// Which saved login to use. Optional because a host can be filed before it /// is known how to get into it; the connection asks at that point. @@ -197,6 +215,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { switch connectionProtocol { case .ssh: sshEnabled case .telnet: telnetEnabled + case .mosh: sshEnabled && terminalTransports.moshEnabled && !isTailscaleManaged + case .eternalTerminal: sshEnabled && terminalTransports.eternalTerminalEnabled } } @@ -204,6 +224,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { switch connectionProtocol { case .ssh: sshPort case .telnet: telnetPort + case .mosh: sshPort + case .eternalTerminal: terminalTransports.eternalTerminalPort } } @@ -211,6 +233,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { switch connectionProtocol { case .ssh: sshIdentityID case .telnet: telnetIdentityID + case .mosh, .eternalTerminal: sshIdentityID } } @@ -219,7 +242,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { } public var preferredTerminalProtocol: HostTerminalProtocol? { - if sshEnabled { return .ssh } + if sshEnabled { return terminalTransports.preferred(isTailscaleManaged: isTailscaleManaged) } if telnetEnabled { return .telnet } return nil } @@ -322,7 +345,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { case charset case sshEnabled, telnetEnabled, telnetPort, telnetIdentityID, telnetCharset, telnetProxy case groupID, tags, colorTag, iconOverrideID, detectedDistribution, sortIndex, shellIntegrationMode - case sessionPersistence, agentForwardingEnabled, startupSnippetID, environmentVariables, + case terminalTransports, sessionPersistence, agentForwardingEnabled, startupSnippetID, environmentVariables, portForwardingMaxConnections } @@ -332,6 +355,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { tailscale = try values.decodeIfPresent(TailscaleHostReference.self, forKey: .tailscale) name = try values.decode(String.self, forKey: .name) host = try values.decode(String.self, forKey: .host) + terminalTransports = + try values.decodeIfPresent(HostTerminalTransports.self, forKey: .terminalTransports) ?? .init() sshEnabled = try values.decodeIfPresent(Bool.self, forKey: .sshEnabled) ?? true sshPort = try values.decode(Int.self, forKey: .port) sshIdentityID = try values.decodeIfPresent(UUID.self, forKey: .identityID) @@ -381,6 +406,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { try values.encodeIfPresent(proxy, forKey: .proxy) try values.encode(sftpChannelInitialPath, forKey: .sftpChannelInitialPath) try values.encode(sshCharset.rawValue, forKey: .charset) + try values.encode(terminalTransports, forKey: .terminalTransports) try values.encode(sshEnabled, forKey: .sshEnabled) try values.encode(telnetEnabled, forKey: .telnetEnabled) try values.encode(telnetPort, forKey: .telnetPort) diff --git a/Frameworks/TermindDomain/Hosts/HostTerminalTransports.swift b/Frameworks/TermindDomain/Hosts/HostTerminalTransports.swift new file mode 100644 index 00000000..054177b3 --- /dev/null +++ b/Frameworks/TermindDomain/Hosts/HostTerminalTransports.swift @@ -0,0 +1,44 @@ +import Foundation + +/// SSH authenticates every transport here. These options never affect auxiliary SSH services. +public nonisolated struct HostTerminalTransports: Codable, Hashable, Sendable { + public var defaultTransport: HostTerminalProtocol = .ssh + public var moshEnabled = false + public var eternalTerminalEnabled = false + public var moshUDPPortRange = "" + public var eternalTerminalPort = 2022 + + public init() {} + + public func available(isTailscaleManaged: Bool) -> [HostTerminalProtocol] { + [.ssh] + (moshEnabled && !isTailscaleManaged ? [.mosh] : []) + + (eternalTerminalEnabled ? [.eternalTerminal] : []) + } + + public func preferred(isTailscaleManaged: Bool) -> HostTerminalProtocol { + available(isTailscaleManaged: isTailscaleManaged).contains(defaultTransport) ? defaultTransport : .ssh + } + + public mutating func normalizeDefault(isTailscaleManaged: Bool) { + defaultTransport = preferred(isTailscaleManaged: isTailscaleManaged) + } + + public var isValid: Bool { + (!moshEnabled || Self.isValidMoshPortRange(moshUDPPortRange)) + && (!eternalTerminalEnabled || (1...65_535).contains(eternalTerminalPort)) + && defaultTransport != .telnet + } + + /// Accept only a port or inclusive range; never interpolate arbitrary text into the bootstrap command. + public static func isValidMoshPortRange(_ value: String) -> Bool { + if value.isEmpty { return true } + let parts = value.split(separator: "-", omittingEmptySubsequences: false) + guard (1...2).contains(parts.count), + parts.allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy({ (48...57).contains($0) }) }), + let first = Int(parts[0]), (1...65_535).contains(first) + else { return false } + guard parts.count == 2 else { return true } + guard let last = Int(parts[1]) else { return false } + return (first...65_535).contains(last) + } +} diff --git a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift index eb758268..bbec5983 100644 --- a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift +++ b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift @@ -97,6 +97,7 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable public var username = "" public var charset: RemoteCharset = .utf8 public var sftpInitialPath = "" + public var terminalTransports = HostTerminalTransports() public var sessionPersistence = SessionPersistencePreferences() public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings public var startupSnippetID: UUID? @@ -104,7 +105,8 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable public init() {} private enum CodingKeys: String, CodingKey { - case sessionPersistence, tags, colorTag, username, charset, sftpInitialPath, shellIntegrationMode, + case terminalTransports, sessionPersistence, tags, colorTag, username, charset, sftpInitialPath, + shellIntegrationMode, startupSnippetID, environmentVariables, detectedDistribution, iconOverrideID } @@ -118,6 +120,8 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable username = try values.decode(String.self, forKey: .username) charset = try values.decode(RemoteCharset.self, forKey: .charset) sftpInitialPath = try values.decode(String.self, forKey: .sftpInitialPath) + terminalTransports = + try values.decodeIfPresent(HostTerminalTransports.self, forKey: .terminalTransports) ?? .init() sessionPersistence = try values.decodeIfPresent(SessionPersistencePreferences.self, forKey: .sessionPersistence) ?? .init(enabled: false) diff --git a/Frameworks/TermindDomain/Sessions/TerminalRecoveryRecord.swift b/Frameworks/TermindDomain/Sessions/TerminalRecoveryRecord.swift new file mode 100644 index 00000000..6350c7ba --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalRecoveryRecord.swift @@ -0,0 +1,29 @@ +import Foundation + +public nonisolated struct TerminalRecoveryRecord: Codable, Identifiable, Sendable { + public let id: UUID + public let host: Host + public let username: String + public let transport: HostTerminalProtocol + public let savedAt: Date + + public init(id: UUID, host: Host, username: String, transport: HostTerminalProtocol) { + self.id = id + var metadata = host + // Environment values can contain secrets; recovery only needs connection identity. + metadata.environmentVariables = [] + self.host = metadata + self.username = username + self.transport = transport + savedAt = Date() + } + + public func matches(_ current: Host) -> Bool { + host.id == current.id && host.host == current.host && host.sshPort == current.sshPort + && host.sshIdentityID == current.sshIdentityID && host.jumpHostID == current.jumpHostID + && host.proxy == current.proxy && host.tailscale == current.tailscale + && (transport != .eternalTerminal + || host.terminalTransports.eternalTerminalPort == current.terminalTransports.eternalTerminalPort) + && current.isEnabled(transport) + } +} diff --git a/Frameworks/TermindDomain/Sessions/TerminalTransportError.swift b/Frameworks/TermindDomain/Sessions/TerminalTransportError.swift new file mode 100644 index 00000000..5aa4de07 --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalTransportError.swift @@ -0,0 +1,21 @@ +import Foundation + +public nonisolated enum TerminalTransportError: LocalizedError, Sendable { + case invalidCheckpoint, unsupportedRoute, bootstrapFailed, unreachable, sessionFailed, suspended + public var errorDescription: String? { + switch self { + case .invalidCheckpoint: + String(localized: "The saved terminal session cannot be restored.") + case .unsupportedRoute: + String(localized: "This terminal transport is unavailable for this connection.") + case .bootstrapFailed: + String(localized: "Unable to start the terminal server. Check that it is installed on the host.") + case .unreachable: + String(localized: "The terminal server did not respond. Check the transport port and firewall.") + case .sessionFailed: + String(localized: "The terminal session cannot continue. Open a new session to connect again.") + case .suspended: + String(localized: "The terminal session is suspended.") + } + } +} diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index 5d7e507c..f6744246 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -44,15 +44,17 @@ public nonisolated struct SyncRecordFormats: Sendable { private static let v1 = Format(readingVersions: [1], writingVersion: 1) public static let vault = Self([ - "host": Format(readingVersions: [1, 2, 4, 5], writingVersion: 5), - "hostGroup": Format(readingVersions: [1, 2, 3, 4, 5], writingVersion: 5), "identity": v1, + "host": Format(readingVersions: [1, 2, 4, 5, 6], writingVersion: 6), + "hostGroup": Format(readingVersions: [1, 2, 3, 4, 5, 6], writingVersion: 6), "identity": v1, "sshKey": Format(readingVersions: [1, 2, 3], writingVersion: 3), "knownHost": v1, "snippet": Format(readingVersions: [1, 2], writingVersion: 2), "snippetPackage": v1, - "portForwarding": v1, "workspaceTemplate": v1, + "portForwarding": v1, "workspaceTemplate": Format(readingVersions: [1, 2], writingVersion: 2), // Legacy readers only; these cases cannot pass Vault upload eligibility. "connectionHistory": v1, "device": v1, ]) - public static let history = Self(["connectionHistory": v1, "device": v1]) + public static let history = Self([ + "connectionHistory": Format(readingVersions: [1, 2], writingVersion: 2), "device": v1, + ]) public static let metrics = Self([ "selection": Format(readingVersions: [1, 2], writingVersion: 2) ]) diff --git a/Frameworks/TermindDomain/Workspaces/WorkspaceTemplate.swift b/Frameworks/TermindDomain/Workspaces/WorkspaceTemplate.swift index 84bb62cb..0400ecdc 100644 --- a/Frameworks/TermindDomain/Workspaces/WorkspaceTemplate.swift +++ b/Frameworks/TermindDomain/Workspaces/WorkspaceTemplate.swift @@ -20,13 +20,16 @@ public nonisolated struct WorkspaceTemplate: Identifiable, Codable, Hashable, Se public nonisolated enum WorkspaceTemplatePane: Codable, Hashable, Sendable { case ssh(hostID: Host.ID) + case mosh(hostID: Host.ID) + case eternalTerminal(hostID: Host.ID) case telnet(hostID: Host.ID) case sftp(hostID: Host.ID) case empty public var hostID: Host.ID? { switch self { - case .ssh(let hostID), .telnet(let hostID), .sftp(let hostID): hostID + case .ssh(let hostID), .mosh(let hostID), .eternalTerminal(let hostID), .telnet(let hostID), .sftp(let hostID): + hostID case .empty: nil } } diff --git a/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV1.swift b/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV1.swift index 3c8e8949..df2773e7 100644 --- a/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV1.swift +++ b/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV1.swift @@ -9,7 +9,7 @@ import TermindPersistence nonisolated enum HistoryCloudRecordV1 { static func write(_ envelope: HistoryEnvelope, into record: CKRecord) throws { try SyncRecordFormats.history.validate(kind: envelope.key.kind.rawValue, version: envelope.formatVersion) - guard envelope.isValid, envelope.version.counter <= UInt64(Int64.max), + guard envelope.formatVersion == 1, envelope.isValid, envelope.version.counter <= UInt64(Int64.max), record.recordType == envelope.key.kind.recordType, record.recordID.recordName == envelope.key.recordName else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } record["formatVersion"] = NSNumber(value: envelope.formatVersion) @@ -35,6 +35,7 @@ nonisolated enum HistoryCloudRecordV1 { case .ssh: record["connectionKind"] = "ssh" as NSString case .telnet: record["connectionKind"] = "telnet" as NSString case .sftp: record["connectionKind"] = "sftp" as NSString + case .mosh, .eternalTerminal: throw CocoaError(.coderInvalidValue) } record["connectedAt"] = value.connectedAt as NSDate record["endedAt"] = value.endedAt as NSDate? @@ -52,7 +53,8 @@ nonisolated enum HistoryCloudRecordV1 { else { throw invalid } // Do not decode a field or apply a default before the kind/version gate. try SyncRecordFormats.history.validate(kind: rawKind, version: versionNumber.intValue) - guard let kind = HistoryRecordKind(rawValue: rawKind), record.recordType == kind.recordType, + guard versionNumber.intValue == 1, + let kind = HistoryRecordKind(rawValue: rawKind), record.recordType == kind.recordType, let key = HistoryRecordKey(recordName: record.recordID.recordName), key.kind == kind, try uuid("entityID", in: record) == key.id, let modifiedAt = record["modifiedAt"] as? Date, diff --git a/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV2.swift b/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV2.swift new file mode 100644 index 00000000..5d058dae --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV2.swift @@ -0,0 +1,111 @@ +import CloudKit +import Foundation +import TermindDomain +import TermindPersistence + +/// CloudKit field contract for History V2. Local journal serialization and +/// legacy Vault JSON are deliberately not the cloud protocol for these records. +/// Register another per-kind reader before changing a field's type or semantics. +nonisolated enum HistoryCloudRecordV2 { + static func write(_ envelope: HistoryEnvelope, into record: CKRecord) throws { + try SyncRecordFormats.history.validate(kind: envelope.key.kind.rawValue, version: envelope.formatVersion) + guard envelope.formatVersion == 2, envelope.isValid, envelope.version.counter <= UInt64(Int64.max), + record.recordType == envelope.key.kind.recordType, record.recordID.recordName == envelope.key.recordName + else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } + record["formatVersion"] = NSNumber(value: envelope.formatVersion) + record["kind"] = envelope.key.kind.rawValue as NSString + record["entityID"] = envelope.key.id.uuidString.lowercased() as NSString + record["modifiedAt"] = envelope.version.modifiedAt as NSDate + record["counter"] = NSNumber(value: envelope.version.counter) + record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString + record["isDeleted"] = NSNumber(value: envelope.isDeleted) + // Clear optional fields explicitly, including tombstones. A CKRecord restored + // from system fields must never retain an old end time or live payload. + let fields = + envelope.key.kind == .device + ? ["displayName"] : ["hostID", "connectionKind", "connectedAt", "endedAt", "sourceDeviceID"] + for field in fields { + record[field] = nil + } + switch envelope.payload { + case .connectionHistory(let value): + record["hostID"] = value.hostID.uuidString.lowercased() as NSString + // Exhaustiveness forces a deliberate V1/V2 decision when domain kinds grow. + switch value.kind { + case .ssh: record["connectionKind"] = "ssh" as NSString + case .telnet: record["connectionKind"] = "telnet" as NSString + case .sftp: record["connectionKind"] = "sftp" as NSString + case .mosh: record["connectionKind"] = "mosh" as NSString + case .eternalTerminal: record["connectionKind"] = "eternalTerminal" as NSString + } + record["connectedAt"] = value.connectedAt as NSDate + record["endedAt"] = value.endedAt as NSDate? + record["sourceDeviceID"] = value.sourceDeviceID?.uuidString.lowercased() as NSString? + case .device(let value): record["displayName"] = value.displayName as NSString + case nil: break + } + } + + static func read(_ record: CKRecord) throws -> HistoryEnvelope { + let invalid = VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + guard let rawKind = record["kind"] as? String, + let versionNumber = record["formatVersion"] as? NSNumber, + versionNumber == NSNumber(value: versionNumber.intValue) + else { throw invalid } + // Do not decode a field or apply a default before the kind/version gate. + try SyncRecordFormats.history.validate(kind: rawKind, version: versionNumber.intValue) + guard versionNumber.intValue == 2, + let kind = HistoryRecordKind(rawValue: rawKind), record.recordType == kind.recordType, + let key = HistoryRecordKey(recordName: record.recordID.recordName), key.kind == kind, + try uuid("entityID", in: record) == key.id, + let modifiedAt = record["modifiedAt"] as? Date, + let counter = record["counter"] as? NSNumber, counter.int64Value >= 0, + counter == NSNumber(value: counter.int64Value), + let deletion = record["isDeleted"] as? NSNumber, + deletion == NSNumber(value: 0) || deletion == NSNumber(value: 1) + else { throw invalid } + let version = VaultVersion( + modifiedAt: modifiedAt, counter: counter.uint64Value, writerID: try uuid("writerID", in: record)) + let payload: HistoryRecordPayload? + if deletion.boolValue { + payload = nil + } else { + switch kind { + case .connectionHistory: + guard let rawConnection = record["connectionKind"] as? String, + ["ssh", "mosh", "eternalTerminal", "telnet", "sftp"].contains(rawConnection), + let connectionKind = ConnectionHistoryEntry.Kind(rawValue: rawConnection), + let connectedAt = record["connectedAt"] as? Date + else { throw invalid } + let endedAt: Date? = try optional("endedAt", in: record) + let deviceID: String? = try optional("sourceDeviceID", in: record) + if let deviceID, UUID(uuidString: deviceID) == nil { throw invalid } + payload = .connectionHistory( + ConnectionHistoryEntry( + id: key.id, hostID: try uuid("hostID", in: record), + kind: connectionKind, connectedAt: connectedAt, + sourceDeviceID: deviceID.flatMap(UUID.init(uuidString:)), endedAt: endedAt)) + case .device: + guard let name = record["displayName"] as? String else { throw invalid } + payload = .device(DeviceRecord(id: key.id, displayName: name)) + } + } + return HistoryEnvelope( + key: key, version: version, isDeleted: deletion.boolValue, payload: payload, + formatVersion: versionNumber.intValue) + } + + private static func uuid(_ field: String, in record: CKRecord) throws -> UUID { + guard let raw = record[field] as? String, let value = UUID(uuidString: raw) else { + throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + return value + } + private static func optional(_ field: String, in record: CKRecord) throws -> Value? { + guard let raw = record[field] else { return nil } + guard let value = raw as? Value else { + throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + return value + } +} diff --git a/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift index 847f44bf..371da4e7 100644 --- a/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift @@ -231,11 +231,18 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche let envelope = outgoing.envelope let record = try CloudSyncRecordCoding.makeRecord( type: envelope.key.kind.recordType, id: recordID, systemFields: outgoing.systemFieldsData) - try HistoryCloudRecordV1.write(envelope, into: record) + if envelope.formatVersion == 2 { + try HistoryCloudRecordV2.write(envelope, into: record) + } else { + try HistoryCloudRecordV1.write(envelope, into: record) + } return record } nonisolated static func envelope(from record: CKRecord) throws -> HistoryEnvelope { - try HistoryCloudRecordV1.read(record) + if (record["formatVersion"] as? NSNumber)?.intValue == 2 { + return try HistoryCloudRecordV2.read(record) + } + return try HistoryCloudRecordV1.read(record) } } diff --git a/Frameworks/TermindPersistence/HistoryJournalV2.swift b/Frameworks/TermindPersistence/HistoryJournalV2.swift new file mode 100644 index 00000000..b83baee7 --- /dev/null +++ b/Frameworks/TermindPersistence/HistoryJournalV2.swift @@ -0,0 +1,84 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +nonisolated struct HistoryJournalV2: Codable { + enum Payload: Codable { + case connectionHistory(HistoryConnectionEntryV2) + case device(VaultWireV1.DeviceRecord) + + init(_ value: HistoryRecordPayload) { + switch value { + case .connectionHistory(let value): self = .connectionHistory(.init(value)) + case .device(let value): self = .device(.init(value)) + } + } + + var domainValue: HistoryRecordPayload { + switch self { + case .connectionHistory(let value): .connectionHistory(value.domainValue) + case .device(let value): .device(value.domainValue) + } + } + } + + var formatVersion: Int + var key: HistoryRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: Payload? + + init(_ value: HistoryEnvelope) { + formatVersion = value.formatVersion + key = value.key + version = value.version + isDeleted = value.isDeleted + payload = value.payload.map(Payload.init) + } + + var domainValue: HistoryEnvelope { + HistoryEnvelope( + key: key, version: version, isDeleted: isDeleted, + payload: payload?.domainValue, formatVersion: formatVersion) + } +} + +nonisolated struct HistoryConnectionEntryV2: Codable, Identifiable { + enum Kind: String, Codable { case ssh, mosh, eternalTerminal, telnet, sftp } + var id: UUID = UUID() + var hostID: Host.ID + var kind: Kind + var connectedAt: Date = Date() + var sourceDeviceID: UUID? + var endedAt: Date? + init( + id: UUID = UUID(), hostID: Host.ID, kind: Kind, connectedAt: Date = Date(), + sourceDeviceID: UUID? = nil, endedAt: Date? = nil + ) { + self.id = id + self.hostID = hostID + self.kind = kind + self.connectedAt = connectedAt + self.sourceDeviceID = sourceDeviceID + self.endedAt = endedAt + } + init(_ value: TermindDomain.ConnectionHistoryEntry) { + self.id = value.id + self.hostID = value.hostID + self.kind = Kind(rawValue: value.kind.rawValue)! + self.connectedAt = value.connectedAt + self.sourceDeviceID = value.sourceDeviceID + self.endedAt = value.endedAt + } + var domainValue: TermindDomain.ConnectionHistoryEntry { + var result = TermindDomain.ConnectionHistoryEntry(hostID: hostID, kind: .ssh) + result.id = id + result.hostID = hostID + result.kind = TermindDomain.ConnectionHistoryEntry.Kind(rawValue: kind.rawValue)! + result.connectedAt = connectedAt + result.sourceDeviceID = sourceDeviceID + result.endedAt = endedAt + return result + } +} diff --git a/Frameworks/TermindPersistence/HistorySyncPersistence.swift b/Frameworks/TermindPersistence/HistorySyncPersistence.swift index ef4b01d1..27e34f84 100644 --- a/Frameworks/TermindPersistence/HistorySyncPersistence.swift +++ b/Frameworks/TermindPersistence/HistorySyncPersistence.swift @@ -47,25 +47,27 @@ public nonisolated enum HistorySyncCodec { guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + if envelope.formatVersion == 2 { return try encoder.encode(HistoryJournalV2(envelope)) } + if case .connectionHistory(let value) = envelope.payload, value.kind.terminalProtocol?.isResumable == true { + throw CocoaError(.coderInvalidValue) + } return try encoder.encode(HistoryJournalV1(envelope)) } - private static let decoder = VersionedRecordDecoder( - Dictionary( - uniqueKeysWithValues: HistoryRecordKind.allCases.map { kind in - ( - kind.rawValue, - [ - 1: { data in - let value = try JSONDecoder().decode(HistoryJournalV1.self, from: data).domainValue - guard value.isValid else { - throw VaultSyncPersistenceError.invalidEnvelope(value.key.recordName) - } - return value - } - ] - ) - })) - public static func decode(_ data: Data) throws -> HistoryEnvelope { try decoder.decode(data) } + private static let decoder: VersionedRecordDecoder = { + var decoders: [String: [Int: @Sendable (Data) throws -> HistoryEnvelope]] = [:] + for kind in HistoryRecordKind.allCases { + decoders[kind.rawValue] = [1: { try JSONDecoder().decode(HistoryJournalV1.self, from: $0).domainValue }] + } + decoders[HistoryRecordKind.connectionHistory.rawValue]?[2] = { + try JSONDecoder().decode(HistoryJournalV2.self, from: $0).domainValue + } + return VersionedRecordDecoder(decoders) + }() + public static func decode(_ data: Data) throws -> HistoryEnvelope { + let value = try decoder.decode(data) + guard value.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(value.key.recordName) } + return value + } } /// Frozen local journal shape. Reuse the shipped value DTOs without adapting through a Vault envelope. diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index bc700095..7d35c476 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -974,6 +974,15 @@ public enum TermindSchemaV54: VersionedSchema { } } +/// Adds SSH-authenticated terminal transport preferences without changing historical models. +public enum TermindSchemaV55: VersionedSchema { + public static let versionIdentifier = Schema.Version(55, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV54.models.filter { $0 != TermindSchemaV54HostModels.PersistentHost.self } + + [TermindSchemaV55HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -991,7 +1000,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self, TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self, TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, TermindSchemaV53.self, - TermindSchemaV54.self, + TermindSchemaV54.self, TermindSchemaV55.self, ] } @@ -1105,11 +1114,12 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV51.self, toVersion: TermindSchemaV52.self), MigrationStage.lightweight(fromVersion: TermindSchemaV52.self, toVersion: TermindSchemaV53.self), MigrationStage.lightweight(fromVersion: TermindSchemaV53.self, toVersion: TermindSchemaV54.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV54.self, toVersion: TermindSchemaV55.self), ] } } -public typealias PersistentHost = TermindSchemaV54HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV55HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV33IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV45KeyModels.PersistentSSHKey public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index f0be1b69..5eb236f9 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -20,7 +20,7 @@ public enum TermindPersistence { #endif // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV54.self) + public static let schema = Schema(versionedSchema: TermindSchemaV55.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV55HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV55HostModels.swift new file mode 100644 index 00000000..c6a46686 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV55HostModels.swift @@ -0,0 +1,144 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +public enum TermindSchemaV55HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var sshEnabled = true + public var terminalTransportsData: Data? + public var port = 22 + public var identityID: UUID? + public var charset = RemoteCharset.utf8.rawValue + public var telnetEnabled = false + public var telnetPort = 23 + public var telnetIdentityID: UUID? + public var telnetCharset = RemoteCharset.utf8.rawValue + public var telnetProxyProtocol: String? + public var telnetProxyHostname = "" + public var telnetProxyPort = 0 + public var telnetProxyUsername = "" + public var telnetProxyIdentityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var sessionPersistenceEnabled = false + public var sessionPersistenceBackend = "tmux" + public var sessionPersistenceOnConnect = "ask" + public var agentForwardingEnabled = false + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var iconOverrideID: String? + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + sshEnabled = value.sshEnabled + terminalTransportsData = try? JSONEncoder().encode(value.terminalTransports) + port = value.sshPort + identityID = value.sshIdentityID + charset = value.sshCharset.rawValue + telnetEnabled = value.telnetEnabled + telnetPort = value.telnetPort + telnetIdentityID = value.telnetIdentityID + telnetCharset = value.telnetCharset.rawValue + telnetProxyProtocol = value.telnetProxy?.kind.rawValue + telnetProxyHostname = value.telnetProxy?.hostname ?? "" + telnetProxyPort = value.telnetProxy?.port ?? 0 + telnetProxyUsername = value.telnetProxy?.username ?? "" + telnetProxyIdentityID = value.telnetProxy?.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + iconOverrideID = value.iconOverrideID + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + agentForwardingEnabled = value.agentForwardingEnabled + sessionPersistenceEnabled = value.sessionPersistence.enabled + sessionPersistenceBackend = value.sessionPersistence.backend.rawValue + sessionPersistenceOnConnect = value.sessionPersistence.onConnect.rawValue + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, identityID: identityID, + charset: RemoteCharset(rawValue: charset) ?? .utf8, + sshEnabled: sshEnabled, telnetEnabled: telnetEnabled, telnetPort: telnetPort, + telnetIdentityID: telnetIdentityID, + telnetCharset: RemoteCharset(rawValue: telnetCharset) ?? .utf8, + groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + iconOverrideID: iconOverrideID, sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, agentForwardingEnabled: agentForwardingEnabled, + sessionPersistence: SessionPersistencePreferences( + enabled: sessionPersistenceEnabled, backend: .init(rawValue: sessionPersistenceBackend), + onConnect: .init(rawValue: sessionPersistenceOnConnect) ?? .ask), + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.terminalTransports = + terminalTransportsData.flatMap { + try? JSONDecoder().decode(HostTerminalTransports.self, from: $0) + } ?? .init() + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let telnetProxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: telnetProxyProtocol) { + host.telnetProxy = HostProxy( + kind: kind, hostname: telnetProxyHostname, port: telnetProxyPort, + username: telnetProxyUsername, identityID: telnetProxyIdentityID) + } + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index ad859f18..a9802bc8 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -66,6 +66,17 @@ public nonisolated enum VaultSyncCodec { guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + if envelope.formatVersion == 6 { + return try encoder.encode(VaultWireV6.Envelope(envelope)) + } + if case .host(let host) = envelope.payload, host.terminalTransports != .init() { + throw CocoaError(.coderInvalidValue) + } + if case .hostGroup(let group) = envelope.payload, + group.tailscale?.hostPreferences.values.contains(where: { $0.terminalTransports != .init() }) == true + { + throw CocoaError(.coderInvalidValue) + } if envelope.formatVersion < 5 { if case .host(let host) = envelope.payload, host.iconOverrideID != nil { throw CocoaError(.coderInvalidValue) @@ -97,6 +108,9 @@ public nonisolated enum VaultSyncCodec { if envelope.formatVersion == 3 { return try encoder.encode(VaultWireV3.Envelope(envelope)) } + if envelope.formatVersion == 2, envelope.key.kind == .workspaceTemplate { + return try encoder.encode(VaultWorkspaceWireV2(envelope)) + } if envelope.formatVersion == 2 { return try encoder.encode(VaultWireV2.Envelope(envelope)) } @@ -108,6 +122,9 @@ public nonisolated enum VaultSyncCodec { for kind in VaultRecordKind.allCases { decoders[kind.rawValue] = [1: { try JSONDecoder().decode(VaultWireV1.Envelope.self, from: $0).domainValue }] } + decoders[VaultRecordKind.workspaceTemplate.rawValue]?[2] = { + try JSONDecoder().decode(VaultWorkspaceWireV2.self, from: $0).domainValue + } decoders[VaultRecordKind.sshKey.rawValue]?[2] = { try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue } @@ -127,6 +144,7 @@ public nonisolated enum VaultSyncCodec { try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue } for kind in [VaultRecordKind.host, .hostGroup] { + decoders[kind.rawValue]?[6] = { try JSONDecoder().decode(VaultWireV6.Envelope.self, from: $0).domainValue } decoders[kind.rawValue]?[5] = { try JSONDecoder().decode(VaultWireV5.Envelope.self, from: $0).domainValue } decoders[kind.rawValue]?[4] = { try JSONDecoder().decode(VaultWireV4.Envelope.self, from: $0).domainValue } } diff --git a/Frameworks/TermindPersistence/VaultWireV1.swift b/Frameworks/TermindPersistence/VaultWireV1.swift index 2e98be0b..d58adf3e 100644 --- a/Frameworks/TermindPersistence/VaultWireV1.swift +++ b/Frameworks/TermindPersistence/VaultWireV1.swift @@ -635,10 +635,10 @@ nonisolated enum VaultWireV1 { self.name = name self.layout = layout } - init(_ value: TermindDomain.WorkspaceTemplate) { + init(_ value: TermindDomain.WorkspaceTemplate) throws { self.id = value.id self.name = value.name - self.layout = WorkspaceTemplateLayout(value.layout) + self.layout = try WorkspaceTemplateLayout(value.layout) } var domainValue: TermindDomain.WorkspaceTemplate { var result = TermindDomain.WorkspaceTemplate(name: name, layout: layout.domainValue) @@ -802,12 +802,13 @@ nonisolated enum VaultWireV1 { case telnet(hostID: UUID) case sftp(hostID: UUID) case empty - init(_ value: TermindDomain.WorkspaceTemplatePane) { + init(_ value: TermindDomain.WorkspaceTemplatePane) throws { switch value { case .ssh(let id): self = .ssh(hostID: id) case .telnet(let id): self = .telnet(hostID: id) case .sftp(let id): self = .sftp(hostID: id) case .empty: self = .empty + case .mosh, .eternalTerminal: throw CocoaError(.coderInvalidValue) } } var domainValue: TermindDomain.WorkspaceTemplatePane { @@ -825,13 +826,13 @@ nonisolated enum VaultWireV1 { case split( axis: WorkspaceTemplateSplitAxis, fraction: Double, first: WorkspaceTemplateLayout, second: WorkspaceTemplateLayout) - init(_ value: TermindDomain.WorkspaceTemplateLayout) { + init(_ value: TermindDomain.WorkspaceTemplateLayout) throws { switch value { - case .pane(let pane): self = .pane(WorkspaceTemplatePane(pane)) + case .pane(let pane): self = .pane(try WorkspaceTemplatePane(pane)) case .split(let axis, let fraction, let first, let second): self = .split( axis: WorkspaceTemplateSplitAxis(rawValue: axis.rawValue)!, fraction: fraction, - first: Self(first), second: Self(second)) + first: try Self(first), second: try Self(second)) } } var domainValue: TermindDomain.WorkspaceTemplateLayout { @@ -856,7 +857,7 @@ nonisolated enum VaultWireV1 { case workspaceTemplate(WorkspaceTemplate) case connectionHistory(ConnectionHistoryEntry) case device(DeviceRecord) - init(_ value: VaultRecordPayload) { + init(_ value: VaultRecordPayload) throws { switch value { case .host(let value): self = .host(Host(value)) case .hostGroup(let value): self = .hostGroup(HostGroup(value)) @@ -866,8 +867,12 @@ nonisolated enum VaultWireV1 { case .snippet(let value): self = .snippet(Snippet(value)) case .snippetPackage(let value): self = .snippetPackage(SnippetPackage(value)) case .portForwarding(let value): self = .portForwarding(PortForwardingRule(value)) - case .workspaceTemplate(let value): self = .workspaceTemplate(WorkspaceTemplate(value)) - case .connectionHistory(let value): self = .connectionHistory(ConnectionHistoryEntry(value)) + case .workspaceTemplate(let value): self = .workspaceTemplate(try WorkspaceTemplate(value)) + case .connectionHistory(let value): + guard ConnectionHistoryEntry.Kind(rawValue: value.kind.rawValue) != nil else { + throw CocoaError(.coderInvalidValue) + } + self = .connectionHistory(ConnectionHistoryEntry(value)) case .device(let value): self = .device(DeviceRecord(value)) } } @@ -899,7 +904,7 @@ nonisolated enum VaultWireV1 { key = value.key version = value.version isDeleted = value.isDeleted - payload = value.payload.map(Payload.init) + payload = try value.payload.map(Payload.init) } var domainValue: VaultEnvelope { VaultEnvelope( diff --git a/Frameworks/TermindPersistence/VaultWireV6.swift b/Frameworks/TermindPersistence/VaultWireV6.swift new file mode 100644 index 00000000..cdd59b8b --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWireV6.swift @@ -0,0 +1,98 @@ +import Foundation +import TermindDomain + +/// V6 protects terminal transport preferences from older sync writers. +nonisolated enum VaultWireV6 { + struct Host: Codable { + var base: VaultWireV5.Host + var terminalTransports: HostTerminalTransports + + init(_ value: TermindDomain.Host) throws { + base = try VaultWireV5.Host(value) + terminalTransports = value.terminalTransports + } + + var domainValue: TermindDomain.Host { + var value = base.domainValue + value.terminalTransports = terminalTransports + return value + } + } + + struct HostGroup: Codable { + var base: VaultWireV5.HostGroup + var terminalTransports: [String: HostTerminalTransports] + + init(_ value: TermindDomain.HostGroup) { + base = VaultWireV5.HostGroup(value) + terminalTransports = Dictionary( + uniqueKeysWithValues: (value.tailscale?.hostPreferences ?? [:]).map { + ($0.key.uuidString.lowercased(), $0.value.terminalTransports) + }) + } + + private enum CodingKeys: String, CodingKey { case base, terminalTransports } + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + base = try values.decode(VaultWireV5.HostGroup.self, forKey: .base) + terminalTransports = try values.decode([String: HostTerminalTransports].self, forKey: .terminalTransports) + guard + terminalTransports.keys.allSatisfy({ key in + guard let id = UUID(uuidString: key), id.uuidString.lowercased() == key else { return false } + return base.domainValue.tailscale?.hostPreferences[id] != nil + }) + else { + throw DecodingError.dataCorruptedError( + forKey: .terminalTransports, in: values, debugDescription: "Invalid host identifier") + } + } + + var domainValue: TermindDomain.HostGroup { + var value = base.domainValue + for (key, transports) in terminalTransports { + if let id = UUID(uuidString: key) { + value.tailscale?.hostPreferences[id]?.terminalTransports = transports + } + } + return value + } + } + + enum Payload: Codable { + case host(Host) + case hostGroup(HostGroup) + } + + struct Envelope: Codable { + var formatVersion: Int + var key: VaultRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: Payload? + + init(_ envelope: VaultEnvelope) throws { + guard [.host, .hostGroup].contains(envelope.key.kind) else { throw CocoaError(.coderInvalidValue) } + formatVersion = 6 + key = envelope.key + version = envelope.version + isDeleted = envelope.isDeleted + switch envelope.payload { + case .host(let host): payload = .host(try Host(host)) + case .hostGroup(let group): payload = .hostGroup(HostGroup(group)) + case nil: payload = nil + default: throw CocoaError(.coderInvalidValue) + } + } + + var domainValue: VaultEnvelope { + let value: VaultRecordPayload? + switch payload { + case .host(let host): value = .host(host.domainValue) + case .hostGroup(let group): value = .hostGroup(group.domainValue) + case nil: value = nil + } + return VaultEnvelope( + key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion) + } + } +} diff --git a/Frameworks/TermindPersistence/VaultWorkspaceWireV2.swift b/Frameworks/TermindPersistence/VaultWorkspaceWireV2.swift new file mode 100644 index 00000000..f6ef3919 --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWorkspaceWireV2.swift @@ -0,0 +1,81 @@ +import Foundation +import TermindDomain + +/// The V2 template envelope is separate from the frozen V1 pane vocabulary. +nonisolated struct VaultWorkspaceWireV2: Codable { + enum Pane: Codable { + case ssh(hostID: UUID) + case mosh(hostID: UUID) + case eternalTerminal(hostID: UUID) + case telnet(hostID: UUID) + case sftp(hostID: UUID) + case empty + init(_ value: WorkspaceTemplatePane) { + switch value { + case .ssh(let id): self = .ssh(hostID: id) + case .mosh(let id): self = .mosh(hostID: id) + case .eternalTerminal(let id): self = .eternalTerminal(hostID: id) + case .telnet(let id): self = .telnet(hostID: id) + case .sftp(let id): self = .sftp(hostID: id) + case .empty: self = .empty + } + } + var value: WorkspaceTemplatePane { + switch self { + case .ssh(let id): .ssh(hostID: id) + case .mosh(let id): .mosh(hostID: id) + case .eternalTerminal(let id): .eternalTerminal(hostID: id) + case .telnet(let id): .telnet(hostID: id) + case .sftp(let id): .sftp(hostID: id) + case .empty: .empty + } + } + } + indirect enum Layout: Codable { + case pane(Pane) + case split(axis: WorkspaceTemplateSplitAxis, fraction: Double, first: Layout, second: Layout) + init(_ value: WorkspaceTemplateLayout) { + switch value { + case .pane(let pane): self = .pane(Pane(pane)) + case .split(let axis, let fraction, let first, let second): + self = .split(axis: axis, fraction: fraction, first: Layout(first), second: Layout(second)) + } + } + var value: WorkspaceTemplateLayout { + switch self { + case .pane(let pane): .pane(pane.value) + case .split(let axis, let fraction, let first, let second): + .split(axis: axis, fraction: fraction, first: first.value, second: second.value) + } + } + } + struct Template: Codable { + var id: UUID + var name: String + var layout: Layout + } + enum Payload: Codable { case workspaceTemplate(Template) } + var formatVersion: Int + var key: VaultRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: Payload? + init(_ envelope: VaultEnvelope) throws { + guard envelope.key.kind == .workspaceTemplate else { throw CocoaError(.coderInvalidValue) } + formatVersion = 2 + key = envelope.key + version = envelope.version + isDeleted = envelope.isDeleted + if case .workspaceTemplate(let template) = envelope.payload { + payload = .workspaceTemplate(.init(id: template.id, name: template.name, layout: Layout(template.layout))) + } + } + var domainValue: VaultEnvelope { + var value: VaultRecordPayload? + if case .workspaceTemplate(let template) = payload { + value = .workspaceTemplate(.init(id: template.id, name: template.name, layout: template.layout.value)) + } + return VaultEnvelope( + key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion) + } +} diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index b6f5437b..6e24c150 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindApplication +import TermindDomain nonisolated enum ConnectionJourneySymbolPlacement { case header @@ -10,6 +11,8 @@ extension ConnectionPurpose { nonisolated func symbol(for placement: ConnectionJourneySymbolPlacement) -> String { switch (self, placement) { case (.sshSession, _): "apple.terminal.fill" + case (.moshSession, _): HostTerminalProtocol.mosh.symbol + case (.eternalTerminalSession, _): HostTerminalProtocol.eternalTerminal.symbol case (.telnetSession, _): "text.rectangle.fill" case (.sftpChannel, .header): "folder.fill.badge.gearshape" case (.sftpChannel, .routeDestination): "folder.fill" @@ -19,6 +22,17 @@ extension ConnectionPurpose { case (.portForwarding, _): "arrow.left.arrow.right" } } + + var protocolName: String { + switch self { + case .sshSession, .metrics, .portForwarding, .keyExport: String(localized: AppStrings.Vault.ssh) + case .moshSession: HostTerminalProtocol.mosh.displayName + case .eternalTerminalSession: HostTerminalProtocol.eternalTerminal.displayName + case .telnetSession: String(localized: AppStrings.Vault.telnet) + case .sftpChannel: String(localized: AppStrings.SFTP.sftp) + case .serialSession: String(localized: AppStrings.Vault.serial) + } + } } public struct ConnectionJourneyHeader: View { @@ -86,16 +100,10 @@ public struct ConnectionJourneyHeader: View { .padding(.horizontal, isEmbedded ? 16 : 34) } - private var title: String { journey.destination?.name ?? String(localized: "SSH Connection") } + private var title: String { journey.destination?.name ?? journey.purpose.protocolName } private var detail: String { - let protocolName: String = - switch journey.purpose { - case .sshSession, .metrics, .portForwarding, .keyExport: String(localized: AppStrings.Vault.ssh) - case .telnetSession: String(localized: AppStrings.Vault.telnet) - case .sftpChannel: String(localized: AppStrings.SFTP.sftp) - case .serialSession: String(localized: AppStrings.Vault.serial) - } + let protocolName = journey.purpose.protocolName let address = journey.destination?.address ?? "" if journey.mode == .reconnection { return String(localized: "Reconnecting \(protocolName) · \(address)") @@ -130,7 +138,8 @@ public struct ConnectionJourneyStatus: View { case .waiting: String(localized: AppStrings.Connections.waitingForYou) case .succeeded: switch journey.purpose { - case .sshSession, .telnetSession, .serialSession: String(localized: AppStrings.Connections.terminalReady) + case .sshSession, .moshSession, .eternalTerminalSession, .telnetSession, .serialSession: + String(localized: AppStrings.Connections.terminalReady) case .sftpChannel: String(localized: AppStrings.Connections.fileBrowserReady) case .metrics: String(localized: AppStrings.Connections.metricsReady) case .keyExport: String(localized: AppStrings.KeyExport.completed) diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index 4e551a27..d9180f4d 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -42,7 +42,7 @@ public struct HostProtocolProfileControls: View { public var body: some View { VStack(alignment: .leading, spacing: 8) { Picker("Protocol Profile", selection: $connectionProtocol) { - ForEach(HostTerminalProtocol.allCases) { connectionProtocol in + ForEach([HostTerminalProtocol.ssh, .telnet]) { connectionProtocol in Text(connectionProtocol.displayName).tag(connectionProtocol) } } diff --git a/Frameworks/TermindPresentation/HostTransportSection.swift b/Frameworks/TermindPresentation/HostTransportSection.swift new file mode 100644 index 00000000..1fa4069d --- /dev/null +++ b/Frameworks/TermindPresentation/HostTransportSection.swift @@ -0,0 +1,63 @@ +import SwiftUI +import TermindDomain + +import struct TermindDomain.Host + +public struct HostTransportSection: View { + @Binding private var host: Host + + public init(host: Binding) { _host = host } + + public var body: some View { + Section(AppStrings.Transports.section.swiftUIKey) { + Picker( + AppStrings.Transports.defaultTransport.swiftUIKey, selection: $host.terminalTransports.defaultTransport + ) { + ForEach(host.terminalTransports.available(isTailscaleManaged: host.isTailscaleManaged)) { transport in + Label { + Text(verbatim: transport.displayName) + } icon: { + Image(systemName: transport.symbol) + }.tag(transport) + } + } + Toggle(isOn: $host.terminalTransports.moshEnabled) { + Text(verbatim: "Mosh") + } + .disabled(host.isTailscaleManaged) + if host.terminalTransports.moshEnabled && !host.isTailscaleManaged { + LabeledContent(AppStrings.Transports.portRange.swiftUIKey) { + TextField( + AppStrings.Transports.portRange.swiftUIKey, + text: $host.terminalTransports.moshUDPPortRange, + prompt: Text(verbatim: "60001-60999").italic() + ) + .labelsHidden().multilineTextAlignment(.trailing) + } + } + Toggle(isOn: $host.terminalTransports.eternalTerminalEnabled) { + Text(verbatim: "Eternal Terminal") + } + if host.terminalTransports.eternalTerminalEnabled { + LabeledContent(AppStrings.Common.port.swiftUIKey) { + TextField( + AppStrings.Common.port.swiftUIKey, + value: $host.terminalTransports.eternalTerminalPort, + format: .number.grouping(.never) + ) + .labelsHidden().multilineTextAlignment(.trailing) + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + } + } + .onChange(of: host.terminalTransports.moshEnabled) { normalizeDefault() } + .onChange(of: host.terminalTransports.eternalTerminalEnabled) { normalizeDefault() } + .onAppear { normalizeDefault() } + } + + private func normalizeDefault() { + host.terminalTransports.normalizeDefault(isTailscaleManaged: host.isTailscaleManaged) + } +} diff --git a/Frameworks/TermindPresentation/Strings/Transports.swift b/Frameworks/TermindPresentation/Strings/Transports.swift new file mode 100644 index 00000000..b479f496 --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/Transports.swift @@ -0,0 +1,18 @@ +import Foundation + +extension AppStrings { + public nonisolated enum Transports { + public static var mosh: LocalizedStringResource { "Mosh" } + public static var eternalTerminal: LocalizedStringResource { "Eternal Terminal" } + public static var withMosh: LocalizedStringResource { "with Mosh" } + public static var withEternalTerminal: LocalizedStringResource { "with Eternal Terminal" } + public static var section: LocalizedStringResource { "Transport" } + public static var defaultTransport: LocalizedStringResource { "Default" } + public static var portRange: LocalizedStringResource { "Port Range" } + public static var retryNow: LocalizedStringResource { "Retry Now" } + public static var closeSession: LocalizedStringResource { "Close Session" } + public static var sessionUnavailable: LocalizedStringResource { + "The terminal session cannot continue. Open a new session to connect again." + } + } +} diff --git a/Frameworks/TermindPresentation/TerminalTransportStatusView.swift b/Frameworks/TermindPresentation/TerminalTransportStatusView.swift new file mode 100644 index 00000000..f4d3a851 --- /dev/null +++ b/Frameworks/TermindPresentation/TerminalTransportStatusView.swift @@ -0,0 +1,33 @@ +import SwiftUI +import TermindApplication + +public struct TerminalTransportStatusView: View { + private let state: ResumableTerminalState? + private let retry: () -> Void + private let close: () -> Void + + public init(state: ResumableTerminalState?, retry: @escaping () -> Void, close: @escaping () -> Void) { + self.state = state + self.retry = retry + self.close = close + } + + public var body: some View { + if state == .recovering || state == .suspended || state == .failed || state == .ended { + VStack(spacing: 12) { + Text( + state == .failed || state == .ended + ? AppStrings.Transports.sessionUnavailable : AppStrings.Connections.reconnecting) + HStack { + if state == .recovering { + Button(AppStrings.Transports.retryNow.swiftUIKey, action: retry) + } + Button(AppStrings.Transports.closeSession.swiftUIKey, role: .destructive, action: close) + } + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .padding() + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift new file mode 100644 index 00000000..894e050f --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift @@ -0,0 +1,494 @@ +import ETBootstrap +import ETSession +import Foundation +import MoshBootstrap +import MoshCore +import Network +import TermindApplication +import TermindDomain + +public actor ResumableTerminalClient: ResumableTerminalSession { + public typealias RouteFactory = @Sendable () async throws -> SSHRoutedSocket + public typealias StateHandler = @Sendable (ResumableTerminalState) -> Void + + private let transport: HostTerminalProtocol + private let output: SSHPTYChannel.OutputHandler + private let stateChanged: StateHandler + private let integrationChanged: SSHPTYChannel.ShellIntegrationHandler + private var mosh: MoshClientSession? + private var eternal: ETTerminalSession? + private var bridge: TerminalTCPBridge? + private var reader: Task? + private var observer: Task? + private var maintenance: Task? + private var pathMonitor: NWPathMonitor? + private var closed = false + private var frozen = false + private var state: ResumableTerminalState = .connecting + private var credentials: ETCredentials? + private var integration: RemoteShellIntegrationBootstrap? + private var commandFilter: RemoteCommandEventFilter? + private var integratedShell: IntegratedShell? + private var pendingCheckpoint: TerminalTransportCheckpoint? + private var pendingIntegrationCommand: Data? + private var sendsInFlight = 0 + private var sendWaiters: [CheckedContinuation] = [] + private var viewport: (columns: UInt16, rows: UInt16) = (80, 24) + + private struct EternalPayload: Codable { + let clientID: String + let passkey: Data + let checkpoint: ETSessionCheckpoint + let integration: RemoteShellIntegrationBootstrap? + let commandFilter: RemoteCommandEventFilter? + let integratedShell: IntegratedShell? + let pendingIntegrationCommand: Data? + } + + private struct IntegratedShell: Codable { + let shell: String + let systemInfo: RemoteSystemInfo? + let homeDirectory: String? + } + + public init( + transport: HostTerminalProtocol, checkpoint: TerminalTransportCheckpoint? = nil, + output: @escaping SSHPTYChannel.OutputHandler, + stateChanged: @escaping StateHandler, + shellIntegration: @escaping SSHPTYChannel.ShellIntegrationHandler + ) { + self.transport = transport + self.pendingCheckpoint = checkpoint + self.output = output + self.stateChanged = stateChanged + self.integrationChanged = shellIntegration + } + + public func connect( + request: SSHConnectionRequest, options: HostTerminalTransports, + environment: [HostEnvironmentVariable], shellIntegrationEnabled: Bool, + columns: UInt16, rows: UInt16, route: @escaping RouteFactory, + progress: SSHRouteProgressHandler?, + startupPreparation: SSHPTYChannel.StartupPreparation? = nil + ) async throws { + guard !closed, options.isValid, transport.isResumable else { throw CancellationError() } + viewport = (columns, rows) + guard options.available(isTailscaleManaged: request.configuration.network != nil).contains(transport) else { + throw TerminalTransportError.unsupportedRoute + } + guard environment.allSatisfy(\.isValid) else { throw TerminalTransportError.bootstrapFailed } + if let pendingCheckpoint, + pendingCheckpoint.version != 1 || pendingCheckpoint.transport != transport + { + throw TerminalTransportError.invalidCheckpoint + } + publish(.connecting) + sendsInFlight += 1 + defer { finishSend() } + let bootstrap = SSHConnection() + var rollbackMoshPID: Int32? + do { + if pendingCheckpoint == nil { + try await bootstrap.connect(using: request, progress: progress, connectionFailure: nil) + try checkOpen() + } + let startup = try await ResumableTerminalStartup.prepare( + restoring: pendingCheckpoint != nil, preparation: startupPreparation, + execute: { command in try await TerminalTransportRoute.execute(command, on: bootstrap) }) + try checkOpen() + if let checkpoint = pendingCheckpoint { + try await restore(checkpoint, route: route) + } else { + if transport == .mosh { + let range = options.moshUDPPortRange.replacingOccurrences(of: "-", with: ":") + var command = "mosh-server new -s -c 256" + if !range.isEmpty { command += " -p \(range)" } + command += try startup.moshArguments + let variables = environment.map { + "'\($0.variable)=\($0.value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + command = (["env"] + variables + ["LANG=C.UTF-8", "LC_ALL=C.UTF-8", command]) + .joined(separator: " ") + let result = try await bootstrap.execute( + command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 65_536, onOutput: nil) + let info: MoshServerConnectInfo + do { + info = try MoshServerOutputParser.parse(result.stdout + result.stderr) + } catch { + throw TerminalTransportError.bootstrapFailed + } + rollbackMoshPID = info.serverPID + mosh = MoshClientSession( + endpoint: .init( + host: request.configuration.host, port: info.port, keyBase64_22: info.key)) + } else { + let bridge = try TerminalTCPBridge(open: route, initial: await route()) + self.bridge = bridge + let executor = BootstrapExecutor(connection: bootstrap) + do { + credentials = try await ETBootstrap().run(using: executor) + } catch { + if let original = await executor.failure { throw original } + throw TerminalTransportError.bootstrapFailed + } + try checkOpen() + guard let credentials else { throw TerminalTransportError.bootstrapFailed } + eternal = try ETTerminalSession( + host: "127.0.0.1", port: bridge.port, + clientID: credentials.clientID, passkey: credentials.passkey, + environmentVariables: Dictionary( + environment.map { ($0.variable, $0.value) }, + uniquingKeysWith: { _, last in last })) + if shellIntegrationEnabled && startup.plan == nil { + let integration = RemoteShellIntegrationBootstrap() + self.integration = integration + commandFilter = RemoteCommandEventFilter(token: integration.token) + } + } + } + try checkOpen() + if let mosh { + try await mosh.start() + try await mosh.enqueue(.resize(cols: Int32(columns), rows: Int32(rows))) + // An open UDP socket is not proof that the server is reachable. + let deadline = ContinuousClock.now.advanced(by: .seconds(20)) + while true { + try checkOpen() + let operations = await mosh.drainHostOps() + if !operations.isEmpty { + deliver(operations) + break + } + if case .failed = await mosh.state { throw TerminalTransportError.sessionFailed } + guard ContinuousClock.now < deadline else { throw TerminalTransportError.unreachable } + try await Task.sleep(for: .milliseconds(20)) + } + rollbackMoshPID = nil + publish(.connected) + startMoshReader(mosh) + } else if let eternal { + startEternalReaders(eternal) + try await eternal.connect(initialDimensions: .init(rows: Int(rows), columns: Int(columns))) + try checkOpen() + if let input = try startup.eternalInput { + try await eternal.send(input) + } + if pendingCheckpoint == nil, let integration { try await eternal.send(integration.startCommand) } + if let command = pendingIntegrationCommand { + pendingIntegrationCommand = nil + try await eternal.send(command) + } + } + await bootstrap.disconnect() + pendingCheckpoint = nil + startMaintenance() + startNetworkMonitoring() + } catch { + if let pid = rollbackMoshPID, pid > 1 { + // Only undo the server this attempt just launched; never kill a user's other sessions. + await Task.detached { + _ = try? await bootstrap.execute( + "[ \"$(ps -p \(pid) -o comm=)\" = mosh-server ] && kill -TERM \(pid)", + workingDirectory: nil, stdin: nil, timeout: 3, outputLimit: 1024, onOutput: nil) + }.value + } + await bootstrap.disconnect() + await close() + throw error + } + } + + private func restore(_ checkpoint: TerminalTransportCheckpoint, route: @escaping RouteFactory) async throws { + if transport == .mosh { + let snapshot = try JSONDecoder().decode(MoshSnapshot.self, from: checkpoint.payload) + mosh = try await MoshClientSession.restore(from: snapshot) + } else { + let payload = try JSONDecoder().decode(EternalPayload.self, from: checkpoint.payload) + credentials = ETCredentials(clientID: payload.clientID, passkey: payload.passkey) + let bridge = try TerminalTCPBridge(open: route, initial: await route()) + self.bridge = bridge + eternal = try ETTerminalSession( + host: "127.0.0.1", port: bridge.port, clientID: payload.clientID, + passkey: payload.passkey, checkpoint: payload.checkpoint) + integration = payload.integration + integration?.resetDeadlinesAfterRestore() + commandFilter = payload.commandFilter + integratedShell = payload.integratedShell + pendingIntegrationCommand = payload.pendingIntegrationCommand + if let shell = integratedShell { + integrationChanged( + .available(shell: shell.shell, systemInfo: shell.systemInfo, homeDirectory: shell.homeDirectory)) + } + } + } + + public func write(_ data: Data) async throws { + try checkOpen() + guard !frozen else { throw TerminalTransportError.suspended } + integration?.noteUserInput() + sendsInFlight += 1 + defer { finishSend() } + if let mosh { try await mosh.enqueue(.keystrokes(data)) } + if let eternal { try await eternal.send(data) } + } + + public func resize(columns: UInt16, rows: UInt16) async throws { + try checkOpen() + viewport = (columns, rows) + guard !frozen else { return } + sendsInFlight += 1 + defer { finishSend() } + if let mosh { try await mosh.enqueue(.resize(cols: Int32(columns), rows: Int32(rows))) } + if let eternal { try await eternal.resize(rows: Int(rows), cols: Int(columns)) } + } + + public func suspend() async throws -> TerminalTransportCheckpoint { + try checkOpen() + guard state == .connected || state == .recovering else { throw TerminalTransportError.sessionFailed } + frozen = true + if sendsInFlight > 0 { + await withCheckedContinuation { sendWaiters.append($0) } + } + try checkOpen() + let payload: Data + if let mosh { + payload = try JSONEncoder().encode(await mosh.prepareForApplicationBackground()) + } else if let eternal, let credentials { + let checkpoint = try await eternal.prepareForApplicationBackground() + payload = try JSONEncoder().encode( + EternalPayload( + clientID: credentials.clientID, passkey: credentials.passkey, + checkpoint: checkpoint, integration: integration, commandFilter: commandFilter, + integratedShell: integratedShell, pendingIntegrationCommand: pendingIntegrationCommand)) + } else { + throw TerminalTransportError.invalidCheckpoint + } + try checkOpen() + publish(.suspended) + return TerminalTransportCheckpoint(transport: transport, payload: payload) + } + + public func resume() async throws { + try checkOpen() + frozen = false + publish(.recovering) + if let mosh { try await mosh.resumeFromApplicationBackground() } + if let eternal { await eternal.resumeFromApplicationBackground() } + try checkOpen() + integration?.resetDeadlinesAfterRestore() + try await resize(columns: viewport.columns, rows: viewport.rows) + if let command = pendingIntegrationCommand { + pendingIntegrationCommand = nil + sendsInFlight += 1 + defer { finishSend() } + try await eternal?.send(command) + } + } + + public func retryNow() async throws { + try checkOpen() + await bridge?.retry() + if frozen { + try await resume() + return + } + if let eternal { await eternal.notifyNetworkPathChanged() } + if let mosh { + _ = try await mosh.prepareForApplicationBackground() + try await mosh.resumeFromApplicationBackground() + } + } + + public func close() async { + guard !closed else { return } + closed = true + reader?.cancel() + observer?.cancel() + maintenance?.cancel() + pathMonitor?.cancel() + pathMonitor = nil + if let mosh { await mosh.stop() } + if let eternal { await eternal.close() } + await bridge?.close() + mosh = nil + eternal = nil + bridge = nil + credentials = nil + pendingCheckpoint = nil + publish(.closed) + } + + private func checkOpen() throws { + try Task.checkCancellation() + guard !closed else { throw CancellationError() } + } + + private func finishSend() { + sendsInFlight -= 1 + if sendsInFlight == 0 { + let waiters = sendWaiters + sendWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + } + + private func publish(_ next: ResumableTerminalState) { + guard !closed || next == .closed, state != next else { return } + state = next + stateChanged(next) + } + + private func startMoshReader(_ session: MoshClientSession) { + reader = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + if case .failed = await session.state { + await self.publish(.failed) + return + } + await self.deliver(session.drainHostOps()) + try? await Task.sleep(for: .milliseconds(16)) + } + } + } + + private func deliver(_ operations: [MoshHostOp]) { + guard !closed else { return } + if !operations.isEmpty, !frozen { publish(.connected) } + for operation in operations { + if case .hostBytes(let data) = operation { output(data) } + } + } + + private func startEternalReaders(_ session: ETTerminalSession) { + reader = Task { [weak self] in + for await data in session.output { + guard let self, !Task.isCancelled else { return } + await self.receive(data) + } + } + observer = Task { [weak self] in + for await state in session.stateChanges { + guard let self, !Task.isCancelled else { return } + await self.receiveEternalState(state) + } + } + } + + private func receiveEternalState(_ state: ETConnectionState) { + guard !closed else { return } + switch state { + case .connected: + if !frozen { publish(.connected) } + case .reconnecting, .disconnected: + if !frozen { publish(.recovering) } + case .failed: publish(.failed) + case .sessionEnded: publish(.ended) + case .closed, .idle, .bootstrapping, .connecting: break + } + } + + private func receive(_ data: Data) async { + guard !closed else { return } + if var integration { + let update = integration.receive(data) + self.integration = update.isComplete ? nil : integration + await apply(update) + } else { + deliverEternalOutput(data) + } + } + + private func apply(_ update: RemoteShellIntegrationBootstrap.Update) async { + if let command = update.command { + if frozen { + pendingIntegrationCommand = command + } else { + sendsInFlight += 1 + do { try await eternal?.send(command) } catch { + if !closed { publish(.failed) } + } + finishSend() + } + } + guard !closed else { return } + if update.integrationInstalled { + let shell = IntegratedShell( + shell: update.shell ?? "unknown", systemInfo: update.systemInfo, homeDirectory: update.homeDirectory) + integratedShell = shell + integrationChanged( + .available(shell: shell.shell, systemInfo: shell.systemInfo, homeDirectory: shell.homeDirectory)) + } + if update.homeResolutionUnavailable { integrationChanged(.homeResolutionUnavailable) } + deliverEternalOutput(update.visibleOutput ?? Data()) + } + + private func deliverEternalOutput(_ data: Data) { + guard !closed else { return } + var visible = data + if var commandFilter { + let update = commandFilter.receive(visible) + self.commandFilter = commandFilter + visible = update.visibleOutput + for event in update.events { + switch event { + case .commandStarted(let command): integrationChanged(.commandStarted(command)) + case .commandFinished(let code, let duration): + integrationChanged(.commandFinished(code, duration: duration)) + case .promptReady: integrationChanged(.promptReady) + case .draft(let draft): integrationChanged(.draft(draft)) + } + } + } + if !visible.isEmpty { output(visible) } + } + + private func startMaintenance() { + maintenance = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(100)) } catch { return } + await self?.pollIntegration() + } + } + } + + private func pollIntegration() async { + guard !closed, !frozen, var integration else { return } + let update = integration.poll() + self.integration = update.isComplete ? nil : integration + await apply(update) + } + + private func startNetworkMonitoring() { + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + let available = path.status == .satisfied + Task { await self?.networkPathChanged(available: available) } + } + monitor.start(queue: DispatchQueue(label: "com.akinokaede.termind.terminal-network")) + pathMonitor = monitor + } + + private func networkPathChanged(available: Bool) async { + guard !closed, !frozen, state == .connected || state == .recovering else { return } + if !available { publish(.recovering) } + // Never bootstrap another shell: both engines retain their own protocol state. + if available { await eternal?.notifyNetworkPathChanged() } + } +} + +private actor BootstrapExecutor: ETBootstrapExecutor { + let connection: SSHConnection + private(set) var failure: (any Error)? + init(connection: SSHConnection) { self.connection = connection } + func run(command: String) async throws -> String { + do { + let result = try await connection.execute( + command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 65_536, onOutput: nil) + return result.stdout + result.stderr + } catch { + failure = error + throw error + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalStartup.swift b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalStartup.swift new file mode 100644 index 00000000..211badea --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalStartup.swift @@ -0,0 +1,29 @@ +import Foundation +import TermindApplication + +nonisolated struct ResumableTerminalStartup { + let plan: SSHPTYStartupPlan? + + static func prepare( + restoring: Bool, preparation: SSHPTYChannel.StartupPreparation?, + execute: @escaping RemoteSessionCommandExecutor + ) async throws -> Self { + // A restored transport already owns its remote PTY, including any attached tmux client. + guard !restoring else { return Self(plan: nil) } + return Self(plan: try await preparation?(execute)) + } + + var moshArguments: String { + get throws { + guard let plan else { return "" } + return " -- /bin/sh -c \(try RemoteTmuxCommands.quote(plan.command))" + } + } + + var eternalInput: Data? { + get throws { + guard let plan else { return nil } + return Data((" exec /bin/sh -c \(try RemoteTmuxCommands.quote(plan.command))\r").utf8) + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 03068988..de313745 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -10,7 +10,7 @@ import struct TermindDomain.Host /// Installs a small cwd-reporting hook before remote output reaches Ghostty. /// The probe, its echo, and the installation command stay inside this state /// machine; only the login banner and the final integrated prompt are emitted. -public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { +public nonisolated struct RemoteShellIntegrationBootstrap: Codable, Sendable { enum Diagnostic: Sendable, Equatable { enum TimeoutStage: String, Sendable { case lateProbe = "late_probe" @@ -41,7 +41,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { var diagnostic: Diagnostic? } - private enum Phase: Sendable { + private enum Phase: Codable, Sendable { case probing case scanningForLateProbe case suppressingLateProbe @@ -103,6 +103,12 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { userDidWrite = true } + /// Monotonic deadlines from another process cannot be reused after restoration. + mutating func resetDeadlinesAfterRestore(now: UInt64 = DispatchTime.now().uptimeNanoseconds) { + deadline = now + Self.responseTimeout + if lastActivity != nil { lastActivity = now } + } + public mutating func receive( _ data: Data, now: UInt64 = DispatchTime.now().uptimeNanoseconds ) -> Update { @@ -558,7 +564,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { /// Removes authenticated command-report OSC sequences before output reaches /// Ghostty and emits their decoded payloads separately. The session token makes /// ordinary OSC output in a remote program ineligible to forge local history. -public nonisolated struct RemoteCommandEventFilter: Sendable { +public nonisolated struct RemoteCommandEventFilter: Codable, Sendable { public static let maximumPayloadBytes = 64 * 1024 public nonisolated enum Event: Sendable, Equatable { @@ -731,6 +737,15 @@ public actor SSHPTYChannel { private var environmentVariables: [HostEnvironmentVariable] = [] private var environmentRejection: EnvironmentRejectionHandler = { _ in } private var startupPreparation: StartupPreparation? + private let transportOutput: OutputHandler + private let transportIntegration: ShellIntegrationHandler + private var resumable: ResumableTerminalClient? + private var transportOptions = HostTerminalTransports() + private var transportKind: HostTerminalProtocol = .ssh + private var transportCheckpoint: TerminalTransportCheckpoint? + private var transportRoute: ResumableTerminalClient.RouteFactory? + private var transportControl: RemoteSessionCommandExecutor? + private var transportState: ResumableTerminalClient.StateHandler = { _ in } public init( output: @escaping OutputHandler, exit: @escaping ExitHandler, @@ -740,6 +755,8 @@ public actor SSHPTYChannel { logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) { + transportOutput = output + transportIntegration = shellIntegration self.keyboardInteractive = keyboardInteractive self.agentForwardingFactory = agentForwardingFactory worker = SSHPTYChannelWorker( @@ -756,6 +773,21 @@ public actor SSHPTYChannel { ) async throws { defer { request.configuration.clearSecrets() } var request = request.withKeyboardInteractiveFallback(keyboardInteractive) + if transportKind.isResumable { + request.agentForwarding = nil + request.permitsAgentForwarding = false + guard let transportRoute else { throw TerminalTransportError.unsupportedRoute } + let client = ResumableTerminalClient( + transport: transportKind, checkpoint: transportCheckpoint, output: transportOutput, + stateChanged: transportState, shellIntegration: transportIntegration) + resumable = client + try await client.connect( + request: request, options: transportOptions, environment: environmentVariables, + shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, + route: transportRoute, progress: progress, startupPreparation: startupPreparation) + transportCheckpoint = nil + return + } request.agentForwarding = try await agentForwardingFactory?(request) try await worker.connect( using: request, @@ -779,8 +811,42 @@ public actor SSHPTYChannel { startupPreparation = preparation } + public func configureTransport( + _ kind: HostTerminalProtocol, options: HostTerminalTransports, + checkpoint: TerminalTransportCheckpoint? = nil, + route: @escaping ResumableTerminalClient.RouteFactory, + control: RemoteSessionCommandExecutor? = nil, + stateChanged: @escaping ResumableTerminalClient.StateHandler + ) { + transportKind = kind + transportOptions = options + transportCheckpoint = checkpoint + transportRoute = route + transportControl = control + transportState = stateChanged + } + + public func suspendTransport() async throws -> TerminalTransportCheckpoint { + guard let resumable else { throw TerminalTransportError.invalidCheckpoint } + return try await resumable.suspend() + } + + public func resumeTransport() async throws { + guard let resumable else { throw TerminalTransportError.invalidCheckpoint } + try await resumable.resume() + } + + public func retryTransport() async throws { + guard let resumable else { throw TerminalTransportError.invalidCheckpoint } + try await resumable.retryNow() + } + public func sessionCommandExecutor() async throws -> RemoteSessionCommandExecutor { - try await worker.sessionCommandExecutor() + if transportKind.isResumable { + guard let transportControl else { throw TermindSSHError.disconnected } + return transportControl + } + return try await worker.sessionCommandExecutor() } public func runtimeCommandExecutor() async throws -> RemoteSessionCommandExecutor { @@ -796,10 +862,25 @@ public actor SSHPTYChannel { } public func releaseTerminalForControl() async throws { + if let resumable { + await resumable.close() + return + } try await worker.releaseTerminalForControl() } - public func write(_ data: Data) { + public func write(_ data: Data) async { + if let resumable { + do { + try await resumable.write(data) + } catch TerminalTransportError.suspended { + // Background checkpoints must remain frozen until eligibility is invalidated. + } catch is CancellationError { + } catch { + transportState(.failed) + } + return + } worker.write(data) } @@ -823,14 +904,25 @@ public actor SSHPTYChannel { try await worker.historySource() } - public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { + public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) async { + if let resumable { + do { try await resumable.resize(columns: columns, rows: rows) } catch { transportState(.failed) } + return + } worker.resize( columns: columns, rows: rows, widthPixels: widthPixels, heightPixels: heightPixels ) } - public func disconnect() async { await worker.disconnect() } + public func disconnect() async { + await resumable?.close() + resumable = nil + transportCheckpoint = nil + transportRoute = nil + transportControl = nil + await worker.disconnect() + } } nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTCPBridge.swift b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTCPBridge.swift new file mode 100644 index 00000000..ead40ff5 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTCPBridge.swift @@ -0,0 +1,191 @@ +import Darwin +import Foundation +import TermindApplication + +/// Adapts the ET library's TCP-only endpoint API to our routed socket owner. +/// Socket ownership and bounded queues stay on one serial queue, including cancellation. +nonisolated final class TerminalTCPBridge: @unchecked Sendable { + typealias Open = @Sendable () async throws -> SSHRoutedSocket + let port: UInt16 + private let queue = DispatchQueue(label: "com.akinokaede.termind.terminal-tcp-bridge") + private let open: Open + private var listener: Int32 + private var local: Int32 = -1 + private var remote: SSHRoutedSocket? + private var timer: (any DispatchSourceTimer)? + private var opening: Task? + private var generation = UUID() + private var toLocal = Data() + private var toRemote = Data() + private var localEOF = false + private var remoteEOF = false + private var closed = false + private var openingBlocked = false + + init(open: @escaping Open, initial: SSHRoutedSocket? = nil) throws { + self.open = open + self.remote = initial + if let initial { Self.configure(initial.socket) } + listener = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard listener >= 0 else { throw POSIXError(.ENOBUFS) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let fd = listener + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + guard bound == 0, named == 0, listen(fd, 1) == 0 else { + Darwin.close(fd) + throw POSIXError(.EADDRNOTAVAIL) + } + port = UInt16(bigEndian: address.sin_port) + Self.configure(fd) + let timer = DispatchSource.makeTimerSource(queue: queue) + self.timer = timer + timer.schedule(deadline: .now(), repeating: .milliseconds(20)) + timer.setEventHandler { [weak self] in self?.pump() } + timer.resume() + } + + deinit { + timer?.cancel() + opening?.cancel() + if listener >= 0 { Darwin.close(listener) } + if local >= 0 { Darwin.close(local) } + remote?.close() + } + + func close() async { + await withCheckedContinuation { continuation in + queue.async { [self] in + closed = true + timer?.cancel() + timer = nil + retire() + if listener >= 0 { + Darwin.close(listener) + listener = -1 + } + continuation.resume() + } + } + } + + func retry() async { + await withCheckedContinuation { continuation in + queue.async { + self.openingBlocked = false + continuation.resume() + } + } + } + + private static func configure(_ fd: Int32) { + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + } + + private func retire() { + generation = UUID() + opening?.cancel() + opening = nil + if local >= 0 { + Darwin.close(local) + local = -1 + } + remote?.close() + remote = nil + toLocal.removeAll() + toRemote.removeAll() + localEOF = false + remoteEOF = false + } + + private func pump() { + guard !closed else { return } + let accepted = Darwin.accept(listener, nil, nil) + if accepted >= 0 { + if openingBlocked { + Darwin.close(accepted) + return + } + if local >= 0, remote != nil { retire() } + if local >= 0 { Darwin.close(local) } + local = accepted + Self.configure(accepted) + toLocal.removeAll() + toRemote.removeAll() + if remote == nil, opening == nil { startOpening() } + } + guard local >= 0, let remote else { return } + guard Self.move(from: local, to: remote.socket, buffer: &toRemote, eof: &localEOF), + Self.move(from: remote.socket, to: local, buffer: &toLocal, eof: &remoteEOF) + else { + retire() + return + } + if localEOF && remoteEOF { retire() } + } + + private func startOpening() { + let token = generation + opening = Task { [weak self, open] in + do { + let socket = try await open() + guard let self else { + socket.close() + return + } + queue.async { + guard !self.closed, self.generation == token else { + socket.close() + return + } + Self.configure(socket.socket) + self.remote = socket + self.opening = nil + } + } catch { + guard let self else { return } + let sshError = error as? TermindSSHError + let blocksRetry = + error is CancellationError || error is SSHInteractionRequired + || sshError?.isLocalAuthenticationRequirement == true + || sshError?.shouldRequestAuthentication == true + || sshError?.shouldRequestHostKey == true || sshError?.isHostKeyChanged == true + queue.async { + guard self.generation == token else { return } + self.openingBlocked = blocksRetry + self.retire() + } + } + } + } + + private static func move(from input: Int32, to output: Int32, buffer: inout Data, eof: inout Bool) -> Bool { + guard !eof else { return true } + if buffer.isEmpty { + var bytes = [UInt8](repeating: 0, count: 32_768) + let count = Darwin.recv(input, &bytes, bytes.count, 0) + if count == 0 { + eof = true + _ = Darwin.shutdown(output, SHUT_WR) + return true + } + if count < 0 { return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR } + buffer.append(contentsOf: bytes.prefix(count)) + } + let sent = buffer.withUnsafeBytes { Darwin.send(output, $0.baseAddress, $0.count, 0) } + if sent > 0 { buffer.removeFirst(sent) } + return sent >= 0 || errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift new file mode 100644 index 00000000..7c2034c2 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift @@ -0,0 +1,39 @@ +import Foundation +import TermindApplication +import TermindDomain + +public nonisolated enum TerminalTransportRoute { + public static func execute(_ command: String, using request: SSHConnectionRequest) async throws + -> RemoteSessionCommandResult + { + defer { request.configuration.clearSecrets() } + let connection = SSHConnection() + do { + try await connection.connect(using: request) + let result = try await execute(command, on: connection) + await connection.disconnect() + return result + } catch { + await connection.disconnect() + throw error + } + } + + static func execute(_ command: String, on connection: SSHConnection) async throws -> RemoteSessionCommandResult { + let result = try await connection.execute( + command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 262_144, onOutput: nil) + return RemoteSessionCommandResult( + stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, + truncated: result.stdoutTruncated || result.stderrTruncated) + } + + public static func open(using request: SSHConnectionRequest, port: Int) async throws -> SSHRoutedSocket { + defer { request.configuration.clearSecrets() } + let config = request.configuration + return try await SSHRoutedSocket.open( + host: config.host, port: port, proxy: config.proxy, jumps: config.jumps, timeout: 20, + authorizeHostKey: request.authorizeHostKey, network: config.network, + keyboardInteractive: request.keyboardInteractive, securityKeySigning: request.securityKeySigning, + interactionPolicy: request.interactionPolicy, hostKeyConstraints: request.hostKeyConstraints) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Security/TerminalRecoveryStore.swift b/Frameworks/TermindRemoteInfrastructure/Security/TerminalRecoveryStore.swift new file mode 100644 index 00000000..0edab74e --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Security/TerminalRecoveryStore.swift @@ -0,0 +1,141 @@ +import CryptoKit +import Foundation +import Security +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +/// One device-local store, independent of SwiftData/Vault/CloudKit. Leases fence saves racing an explicit close. +public actor TerminalRecoveryStore { + public static let shared = TerminalRecoveryStore() + private let directory: URL + private let testingKey: Data? + private var owners: [UUID: UUID] = [:] + + private struct Envelope: Codable { + let metadata: Data + let ciphertext: Data + } + + public init(directory: URL? = nil, testingKey: Data? = nil) { + self.directory = + directory + ?? URL.applicationSupportDirectory + .appendingPathComponent("Termind/TerminalRecovery", isDirectory: true) + self.testingKey = testingKey + } + + public func register(_ id: UUID) -> UUID { + let lease = UUID() + owners[id] = lease + return lease + } + + public func record(_ id: UUID) throws -> TerminalRecoveryRecord { + let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: location(id))) + _ = try AES.GCM.open( + AES.GCM.SealedBox(combined: envelope.ciphertext), using: key(), authenticating: envelope.metadata) + let record = try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata) + guard record.id == id else { throw TerminalTransportError.invalidCheckpoint } + return record + } + + public func records() throws -> [TerminalRecoveryRecord] { + guard FileManager.default.fileExists(atPath: directory.path) else { return [] } + return try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "terminal" } + .map { + let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: $0)) + return try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata) + } + .sorted { $0.savedAt > $1.savedAt } + } + + public func save( + _ record: TerminalRecoveryRecord, checkpoint: TerminalTransportCheckpoint, lease: UUID + ) throws { + guard owners[record.id] == lease, checkpoint.transport == record.transport else { + throw CancellationError() + } + let metadata = try JSONEncoder().encode(record) + let payload = try JSONEncoder().encode(checkpoint) + let ciphertext = try AES.GCM.seal(payload, using: key(), authenticating: metadata).combined! + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + var excluded = directory + var resources = URLResourceValues() + resources.isExcludedFromBackup = true + try excluded.setResourceValues(resources) + try JSONEncoder().encode(Envelope(metadata: metadata, ciphertext: ciphertext)) + .write(to: location(record.id), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: location(record.id).path) + } + + /// Removes eligibility before returning protocol counters to a caller that can transmit. + public func consume(_ id: UUID, host: Host) throws -> TerminalTransportCheckpoint { + let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: location(id))) + let payload = try AES.GCM.open( + AES.GCM.SealedBox(combined: envelope.ciphertext), using: key(), authenticating: envelope.metadata) + let record = try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata) + guard record.id == id, record.matches(host) else { throw TerminalTransportError.invalidCheckpoint } + let checkpoint = try JSONDecoder().decode(TerminalTransportCheckpoint.self, from: payload) + guard checkpoint.version == 1, checkpoint.transport == record.transport else { + throw TerminalTransportError.invalidCheckpoint + } + try remove(id) + return checkpoint + } + + public func invalidate(_ id: UUID) throws { try remove(id) } + + public func discard(_ id: UUID) throws { + owners.removeValue(forKey: id) + try remove(id) + } + + private func remove(_ id: UUID) throws { + let path = location(id) + if FileManager.default.fileExists(atPath: path.path) { try FileManager.default.removeItem(at: path) } + } + + private func location(_ id: UUID) -> URL { + directory.appendingPathComponent(id.uuidString.lowercased()).appendingPathExtension("terminal") + } + + private func key() throws -> SymmetricKey { + if let testingKey { return SymmetricKey(data: testingKey) } + let match: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.akinokaede.termind.terminal-recovery", + kSecAttrAccount as String: "device-key-v1", + kSecAttrSynchronizable as String: false, + ] + var query = match + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess, let data = result as? Data { return SymmetricKey(data: data) } + guard status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + // A missing key must not silently turn existing checkpoints into unrecoverable ciphertext. + if FileManager.default.fileExists(atPath: directory.path), + try FileManager.default.contentsOfDirectory(atPath: directory.path).contains(where: { + $0.hasSuffix(".terminal") + }) + { + throw TerminalTransportError.invalidCheckpoint + } + let key = SymmetricKey(size: .bits256) + var add = match + add[kSecValueData as String] = key.withUnsafeBytes { Data($0) } + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let inserted = SecItemAdd(add as CFDictionary, nil) + guard inserted == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(inserted)) } + return key + } +} diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index a91823e6..adcc3b1a 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -19,6 +19,7 @@ struct MobileAppDependencies { var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator() var container: ModelContainer? = nil var liveActivities: MobileLiveActivityController? = nil + var terminalRecoveryStore: TerminalRecoveryStore = .shared var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void = MobileWorkspaceSceneRouting.createWindow var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void = diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 0c5b380f..07aed4c1 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -16,6 +16,8 @@ import struct TermindDomain.Host @MainActor @Observable final class MobileAppModel { + let terminalRecoveryStore: TerminalRecoveryStore + private let workspaceStore: MobileWorkspaceStore let container: ModelContainer let hosts: any HostRepository let library: HostLibraryModel @@ -81,7 +83,7 @@ final class MobileAppModel { { workspaceSessions.flatMap { session in session.workspaces.compactMap { workspace in - guard workspace.descriptor.kind == .ssh || workspace.descriptor.kind == .telnet else { + guard workspace.descriptor.kind.isSSHTerminal || workspace.descriptor.kind == .telnet else { return nil } let owner = paneScenes.values.compactMap(\.value).first { @@ -189,6 +191,8 @@ final class MobileAppModel { diagnosis = dependencies.diagnosis logging = dependencies.logging let memoryOnly = inMemory || hosts != nil + terminalRecoveryStore = dependencies.terminalRecoveryStore + workspaceStore = MobileWorkspaceStore(defaults: memoryOnly ? defaults : defaults ?? .standard) let storeURL = URL.applicationSupportDirectory.appending(path: "Termind.store") if !memoryOnly { try FileManager.default.createDirectory( @@ -432,9 +436,26 @@ final class MobileAppModel { metricsSync?.didApplyRemote = { [weak self] snapshot, _ in self?.metrics.core.applyRemoteSnapshot(snapshot) } + do { + let snapshot = try workspaceStore.load() + workspaceSessions = snapshot.sessions.sorted { $0.key < $1.key }.map { + MobileWorkspaceSessionModel(id: $0.key, app: self, descriptors: $0.value) + } + } catch { + self.error = error.localizedDescription + } observeHostArtwork() } + func persistWorkspaceDescriptors() { + let sessions = workspaceSessions.filter { !$0.isDiscarded }.map { ($0.id, $0.restorableDescriptors) } + do { + try workspaceStore.save(.init(sessions: Dictionary(uniqueKeysWithValues: sessions))) + } catch { + self.error = error.localizedDescription + } + } + private func reconcileConnectionPreferences() { let policy = MultiConnectionPolicy(allowsMultipleConnections: pro.canUse(.sftpMultiConnection)) transfers.updatePreferences(policy.sftpPreferences(sftpSettings.preferences)) @@ -458,7 +479,7 @@ final class MobileAppModel { for workspace in workspaces where workspace.descriptor.hostID == hostID { let matches = switch (connectionProtocol, workspace.descriptor.kind) { - case (.ssh, .ssh), (.ssh, .sftp), (.telnet, .telnet): true + case (.ssh, .ssh), (.ssh, .mosh), (.ssh, .eternalTerminal), (.ssh, .sftp), (.telnet, .telnet): true default: false } if matches { workspace.disconnect() } @@ -679,6 +700,7 @@ final class MobileAppModel { transfers.pauseAll() assistant.expireBackground() } + for workspace in workspaces { workspace.suspendResumableTransport() } liveActivities.update(workspaceActivitySessions) } @@ -888,6 +910,7 @@ final class MobileAppModel { } func workspaceDidClose(_ id: UUID) { + Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(id) } endConnectionHistory(for: id) liveActivities.remove(id) for source in activityRebuilds.keys where activityRebuilds[source] == id { @@ -919,6 +942,7 @@ final class MobileAppModel { else { return } workspaceActivitySessions[index] = session liveActivities.update(workspaceActivitySessions) + persistWorkspaceDescriptors() } } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index afce2676..7497af4e 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -120,7 +120,7 @@ final class MobileSceneModel { var canCloseMultipleWorkspaces: Bool { workspaceSession.closableWorkspaceCount > 1 } var workspaceCount: Int { workspaceSession.workspaces.count } var canCloseWorkspace: Bool { activeWorkspace != nil } - var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh } + var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind.isSSHTerminal == true } func openSettings() { selectedDestination = .settings @@ -336,17 +336,17 @@ final class MobileSceneModel { } func generateTerminalCommand() { - guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return } + guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return } workspace.generateCommand(using: app.commandGenerator) } func presentTerminalFind() { - guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return } + guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return } workspace.find.present() } func navigateTerminalFind(_ direction: TerminalFindDirection) { - guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return } + guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return } workspace.find.presentAndNavigate(direction) } diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index 2f653fa9..8da66f4e 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -50,7 +50,9 @@ final class MobileWorkspaceSessionModel { guard orderedWorkspaceItems.contains(where: { $0.id == id }) else { return } manuallyUnreadIDs.insert(id) } - private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] + private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] { + didSet { app.persistWorkspaceDescriptors() } + } private var workspaceDisplayOrder: [MobileWorkspace.ID] var canReopen: Bool { !isDiscarded && !app.closedWorkspaceRecords.isEmpty } var reopensWindow: Bool { app.closedWorkspaceRecords.first?.isWindow == true } @@ -113,6 +115,18 @@ final class MobileWorkspaceSessionModel { return savedWorkspaceDescriptors.filter { !opened.contains($0.id) } } + var restorableDescriptors: [MobileWorkspaceDescriptor] { + savedWorkspaceDescriptors.compactMap { saved in + let descriptor = workspaces.first { $0.id == saved.id }?.restorationDescriptor ?? saved + // Tailnet peers can be rediscovered after the scene's descriptors load. + guard let host = app.library.host(descriptor.hostID) else { return descriptor } + guard host.isEnabled(descriptor.kind.terminalProtocol ?? .ssh) else { return nil } + if descriptor.kind.terminalProtocol?.isResumable == true { return descriptor } + return descriptor.kind == .ssh + && (host.sessionPersistence.enabled || descriptor.remoteSession != nil) ? descriptor : nil + } + } + var orderedWorkspaceItems: [MobileWorkspaceItem] { workspaceDisplayOrder.compactMap { id in let workspace = workspaces.first { $0.id == id } @@ -158,6 +172,10 @@ final class MobileWorkspaceSessionModel { let kind = ConnectionHistoryEntry.Kind(rawValue: recovery.kind), MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) else { return nil } + if kind.terminalProtocol?.isResumable == true { + // A stale activity cannot recreate an explicitly closed resumable session. + return nil + } let workspace = makeWorkspace(host: host, kind: kind) if let data = recovery.remoteSession, let reference = try? JSONDecoder().decode(RemoteSessionReference.self, from: data), @@ -219,14 +237,19 @@ final class MobileWorkspaceSessionModel { return workspace.id } - /// Opens one independent SSH workspace per target. The command is intentionally + /// Opens one workspace per target using its default terminal transport. The command is intentionally /// carried only by the live workspace and never enters its restoration descriptor. func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) -> [MobileWorkspace.ID] { guard !isDiscarded else { return [] } let wanted = Set(hostIDs) let targets = app.library.hosts(matching: "", for: .snippetExecution) .filter { wanted.contains($0.id) } - let opened = targets.map { makeWorkspace(host: $0, kind: .ssh, startup: .command(snippet.body)) } + let opened = targets.map { host in + makeWorkspace( + host: host, + kind: .terminal(host.terminalTransports.preferred(isTailscaleManaged: host.isTailscaleManaged)), + startup: .command(snippet.body)) + } addWorkspaces(opened) return opened.map(\.id) } @@ -295,12 +318,12 @@ final class MobileWorkspaceSessionModel { rememberClosedWorkspaces(knownIDs.subtracting([id])) let closing = workspaces.filter { $0.id != id } + for closedID in knownIDs where closedID != id { app.workspaceDidClose(closedID) } manuallyUnreadIDs = manuallyUnreadIDs.intersection([id]) workspaces.removeAll { $0.id != id } savedWorkspaceDescriptors.removeAll { $0.id != id } workspaceDisplayOrder.removeAll { $0 != id } for workspace in closing { - app.workspaceDidClose(workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil workspace.close() @@ -321,11 +344,13 @@ final class MobileWorkspaceSessionModel { guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return } if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder)) } let closing = workspaces + for id in Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)) { + app.workspaceDidClose(id) + } workspaces.removeAll() savedWorkspaceDescriptors.removeAll() workspaceDisplayOrder.removeAll() for workspace in closing { - app.workspaceDidClose(workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil workspace.close() @@ -380,12 +405,16 @@ final class MobileWorkspaceSessionModel { if let workspace = workspaces.first(where: { $0.id == id }) { return workspace } guard let descriptor = savedWorkspaceDescriptors.first(where: { $0.id == id }) else { return nil } guard app.library.host(descriptor.hostID) != nil else { - close(id, rememberForReopen: false) return nil } let workspace = MobileWorkspace( descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices) + workspace.requiresTransportRecovery = descriptor.kind.terminalProtocol?.isResumable == true + if descriptor.kind.isSSHTerminal { + workspace.restoreRemoteSession(descriptor.remoteSession) + workspace.sessionPersistence.requiresSelection = descriptor.kind == .ssh + } addWorkspaces([workspace]) return workspace } @@ -397,11 +426,11 @@ final class MobileWorkspaceSessionModel { let recordsInitialCommandInHistory: Bool let initialSFTPPath: String? switch (kind, startup) { - case (.ssh, .command(let command)): + case (.ssh, .command(let command)), (.mosh, .command(let command)), (.eternalTerminal, .command(let command)): initialCommand = command recordsInitialCommandInHistory = true initialSFTPPath = nil - case (.ssh, .directory(let path)): + case (.ssh, .directory(let path)), (.mosh, .directory(let path)), (.eternalTerminal, .directory(let path)): initialCommand = RemoteShellCommand.changeDirectory(to: path) recordsInitialCommandInHistory = false initialSFTPPath = nil @@ -409,7 +438,7 @@ final class MobileWorkspaceSessionModel { initialCommand = nil recordsInitialCommandInHistory = true initialSFTPPath = path - case (.ssh, .none), (.sftp, .none), (.sftp, .command), + case (.ssh, .none), (.mosh, .none), (.eternalTerminal, .none), (.sftp, .none), (.sftp, .command), (.telnet, .none), (.telnet, .command), (.telnet, .directory): initialCommand = nil recordsInitialCommandInHistory = true @@ -437,6 +466,7 @@ final class MobileWorkspaceSessionModel { workspaceDisplayOrder.append(id) } for workspace in added { + workspace.terminalRecoveryStore = app.terminalRecoveryStore app.workspaceWillOpen(workspace, close: { [weak self] in self?.close($0) }) } workspaces.append(contentsOf: added) @@ -446,6 +476,7 @@ final class MobileWorkspaceSessionModel { } app.workspacesDidOpen(added) for workspace in added { workspace.connect() } + app.persistWorkspaceDescriptors() } } diff --git a/TFI/Application/FeatureModels/MobileConnectionProtocol.swift b/TFI/Application/FeatureModels/MobileConnectionProtocol.swift index e76e42fa..001eb7ef 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 == .telnet ? .telnet : .ssh + case .ssh: return host.preferredTerminalProtocol.map(ConnectionHistoryEntry.Kind.terminal) case .sftp: return .sftp } } diff --git a/TFI/Application/Workspaces/MobileConnectionHistory.swift b/TFI/Application/Workspaces/MobileConnectionHistory.swift index b71577df..f95527d4 100644 --- a/TFI/Application/Workspaces/MobileConnectionHistory.swift +++ b/TFI/Application/Workspaces/MobileConnectionHistory.swift @@ -24,11 +24,11 @@ struct MobileRecentHostRow: Identifiable { } enum MobileConnectionHistoryActionPolicy { - static let menuKinds: [ConnectionHistoryEntry.Kind] = [.ssh, .telnet, .sftp] + static let menuKinds: [ConnectionHistoryEntry.Kind] = [.ssh, .mosh, .eternalTerminal, .telnet, .sftp] // Trailing swipe actions are declared from the edge outwards, so reversing // the menu order keeps the revealed protocol order consistent. - static let swipeKinds: [ConnectionHistoryEntry.Kind] = [.sftp, .telnet, .ssh] + static let swipeKinds: [ConnectionHistoryEntry.Kind] = [.sftp, .telnet, .eternalTerminal, .mosh, .ssh] static func availableMenuKinds(for host: Host) -> [ConnectionHistoryEntry.Kind] { menuKinds.filter { isAvailable($0, for: host) } @@ -42,6 +42,8 @@ enum MobileConnectionHistoryActionPolicy { switch kind { case .ssh, .sftp: host.sshEnabled case .telnet: host.telnetEnabled + case .mosh: host.isEnabled(.mosh) + case .eternalTerminal: host.isEnabled(.eternalTerminal) } } } @@ -99,6 +101,8 @@ 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 } diff --git a/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift b/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift index ebe01267..6ee44500 100644 --- a/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift +++ b/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift @@ -6,6 +6,7 @@ nonisolated struct MobileWorkspaceDescriptor: Codable, Hashable, Identifiable, S var hostID: UUID var title: String var kind: ConnectionHistoryEntry.Kind + var remoteSession: RemoteSessionReference? var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) } } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index b40f9cd4..265682a2 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -51,7 +51,7 @@ struct MobileHostEditor: View { || !customIdentity.telnetProxyPassword.isEmpty, canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && draft.preferredTerminalProtocol != nil - && (!draft.sshEnabled || (1...65_535).contains(draft.sshPort)) + && (!draft.sshEnabled || (draft.terminalTransports.isValid && (1...65_535).contains(draft.sshPort))) && (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort)) && (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft)) && customIdentity.isProxyValid(for: draft) @@ -90,6 +90,7 @@ struct MobileHostEditor: View { } HostConnectionFields(host: $draft, protocol: selectedProtocol).disabled(draft.isTailscaleManaged) if selectedProtocol == .ssh && draft.sshEnabled { + HostTransportSection(host: $draft) if draft.isTailscaleManaged { Section(AppStrings.Vault.authentication.swiftUIKey) { TextField(AppStrings.Vault.username.swiftUIKey, text: $tailscaleUsername) diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 549ad6e2..a19569d8 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -15,6 +15,8 @@ enum MobileHistoryPresentation { enum MobileHostMenuAction: Hashable, Sendable { case ssh + case mosh + case eternalTerminal case telnet case sftp case edit @@ -25,7 +27,7 @@ enum MobileHostMenuAction: Hashable, Sendable { nonisolated enum MobileHostMenuPolicy { static let sections: [[MobileHostMenuAction]] = [ - [.ssh, .telnet, .sftp], + [.ssh, .mosh, .eternalTerminal, .telnet, .sftp], [.tailscale], [.edit, .duplicate], [.delete], @@ -37,6 +39,8 @@ nonisolated enum MobileHostMenuPolicy { switch action { case .ssh, .sftp: host.sshEnabled case .telnet: host.telnetEnabled + case .mosh: host.isEnabled(.mosh) + case .eternalTerminal: host.isEnabled(.eternalTerminal) case .tailscale: host.isTailscaleManaged || hasTailscalePeer case .edit, .duplicate, .delete: true } @@ -194,7 +198,7 @@ struct MobileConnectionHistoryView: View { private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { switch kind { - case .ssh: .accentColor + case .ssh, .mosh, .eternalTerminal: .accentColor case .telnet: .purple case .sftp: .orange } @@ -811,6 +815,17 @@ struct MobileVaultView: View { Button(AppStrings.Vault.ssh.swiftUIKey, systemImage: ConnectionHistoryEntry.Kind.ssh.symbol) { open(host.id, .ssh) } + case .mosh: + Button(AppStrings.Transports.mosh.swiftUIKey, systemImage: HostTerminalProtocol.mosh.symbol) { + open(host.id, .mosh) + } + case .eternalTerminal: + Button( + AppStrings.Transports.eternalTerminal.swiftUIKey, + systemImage: HostTerminalProtocol.eternalTerminal.symbol + ) { + open(host.id, .eternalTerminal) + } case .telnet: Button(AppStrings.Vault.telnet.swiftUIKey, systemImage: ConnectionHistoryEntry.Kind.telnet.symbol) { open(host.id, .telnet) diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 9e92c3bc..64145b82 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -91,6 +91,25 @@ final class MobileWorkspace: Identifiable { let transfers: SFTPTransferCoordinator? private var transferContext: SFTPTransferContext? private var transferSnapshot: SSHAdditionalConnectionSnapshot? + var resumeCheckpoint: TerminalTransportCheckpoint? + var requiresTransportRecovery = false + var terminalRecoveryStore: TerminalRecoveryStore = .shared + private var restoringTransport = false + private var resumeUsername: String? + private(set) var transportState: ResumableTerminalState? + var recoveryID: UUID { id } + private var recoveryLease: UUID? + private var recoveryRecord: TerminalRecoveryRecord? + private var transportSuspension: Task? + private var transport: HostTerminalProtocol { descriptor.kind.terminalProtocol ?? .ssh } + private var connectionPurpose: ConnectionPurpose { + descriptor.kind.terminalProtocol.map(ConnectionPurpose.terminal) ?? .sftpChannel + } + var restorationDescriptor: MobileWorkspaceDescriptor { + var result = descriptor + result.remoteSession = sessionPersistence.attachment + return result + } private(set) var uploadNotice: WorkspaceUploadNotice? private var pendingUpload: MobilePendingUpload? private var uploadPreparationTask: Task? @@ -156,12 +175,12 @@ final class MobileWorkspace: Identifiable { var uploadConnectionGeneration: UUID { generation } func configureTerminalUpload(files: (() -> Void)?, photos: (() -> Void)? = nil) { - surfaceOwner.view.uploadFiles = descriptor.kind == .ssh ? files : nil - surfaceOwner.view.uploadPhotos = descriptor.kind == .ssh ? photos : nil + surfaceOwner.view.uploadFiles = descriptor.kind.isSSHTerminal ? files : nil + surfaceOwner.view.uploadPhotos = descriptor.kind.isSSHTerminal ? photos : nil surfaceOwner.view.canUploadFiles = { [weak self] in self?.uploadTargetProblem == nil && self != nil } } - var uploadTerminalView: UIView? { descriptor.kind == .ssh ? surfaceOwner.view : nil } + var uploadTerminalView: UIView? { descriptor.kind.isSSHTerminal ? surfaceOwner.view : nil } let terminal: TerminalViewState private let backend: InMemoryTerminalSession @@ -209,7 +228,7 @@ final class MobileWorkspace: Identifiable { var sameDirectoryLaunch: MobileWorkspaceDirectoryLaunch? { guard connected else { return nil } switch descriptor.kind { - case .ssh: + case .ssh, .mosh, .eternalTerminal: guard isShellIntegrationEnabled, shellIntegrationAvailable, let workingDirectory, @@ -234,7 +253,7 @@ final class MobileWorkspace: Identifiable { var uploadDestination: String? { switch descriptor.kind { - case .ssh: + case .ssh, .mosh, .eternalTerminal: guard uploadTargetProblem == nil, let workingDirectory else { return nil } return RemotePath.normalized(workingDirectory) case .sftp: @@ -247,7 +266,7 @@ final class MobileWorkspace: Identifiable { var uploadTargetProblem: String? { switch descriptor.kind { - case .ssh: + case .ssh, .mosh, .eternalTerminal: return TerminalUploadAvailability.problem( isConnected: connected, hasTransferConfiguration: transferSnapshot != nil, @@ -462,7 +481,7 @@ final class MobileWorkspace: Identifiable { cancelCommandGeneration() return } - guard descriptor.kind == .ssh, canGenerateFromCurrentDraft else { + guard descriptor.kind.isSSHTerminal, canGenerateFromCurrentDraft else { showCommandGenerationFailure( String( localized: @@ -516,7 +535,7 @@ final class MobileWorkspace: Identifiable { } func receiveWorkingDirectory(_ path: String?) { - guard descriptor.kind != .ssh || isShellIntegrationEnabled else { return } + guard !descriptor.kind.isSSHTerminal || isShellIntegrationEnabled else { return } guard !hasRuntimeContext else { return } let path = path?.isEmpty == false ? path : nil guard workingDirectory != path else { return } @@ -756,9 +775,78 @@ final class MobileWorkspace: Identifiable { } } + private func restoreTransport() { + guard !restoringTransport else { return } + restoringTransport = true + let token = generation + attempt = Task { [weak self] in + guard let self else { return } + defer { restoringTransport = false } + do { + guard let host = connectionHost(descriptor.hostID), host.isEnabled(transport) else { + throw TerminalTransportError.invalidCheckpoint + } + let record = try await terminalRecoveryStore.record(recoveryID) + let currentUsername = + host.isTailscaleManaged + ? hosts.savedTailscaleUsername(for: host) : hosts.identity(for: host)?.username + guard record.transport == transport, + currentUsername == nil || currentUsername == record.username + else { throw TerminalTransportError.invalidCheckpoint } + try Task.checkCancellation() + guard generation == token else { return } + let checkpoint = try await terminalRecoveryStore.consume(recoveryID, host: host) + try Task.checkCancellation() + guard generation == token else { return } + resumeCheckpoint = checkpoint + resumeUsername = record.username + requiresTransportRecovery = false + pendingCommand = nil + pendingConnectionStartupCommand = nil + connect() + } catch { + guard generation == token, !Task.isCancelled else { return } + if sessionPersistence.attachment != nil { + do { + try await terminalRecoveryStore.discard(recoveryID) + guard generation == token, !Task.isCancelled else { return } + // tmux has its own server-side identity; validate it over a new transport, + // never replay the launch command or silently replace a missing session. + requiresTransportRecovery = false + resumeCheckpoint = nil + resumeUsername = nil + sessionPersistence.requiresSelection = true + pendingCommand = nil + pendingConnectionStartupCommand = nil + connect() + return + } catch { + guard generation == token, !Task.isCancelled else { return } + } + } + transportState = .failed + connectionFailure = TerminalTransportError.invalidCheckpoint.localizedDescription + status = connectionFailure ?? "" + busy = false + } + } + } + func connect() { + if transportSuspension != nil { + resumeAfterBackground() + return + } + if requiresTransportRecovery { + restoreTransport() + return + } + if transport.isResumable, let ssh { + Task { try? await ssh.retryTransport() } + return + } guard !busy, !connected else { return } - if descriptor.kind != .ssh, sessionPersistence.requiresSelection { + if !descriptor.kind.isSSHTerminal, sessionPersistence.requiresSelection { busy = true presentConnectionJourney(mode: .connection) attempt = Task { [weak self] in @@ -780,7 +868,7 @@ final class MobileWorkspace: Identifiable { let token = UUID() generation = token commandMonitor.beginConnection() - if descriptor.kind == .ssh { commandMonitor.requireScopedEvents() } + if descriptor.kind.isSSHTerminal { commandMonitor.requireScopedEvents() } activeHistoryID = nil resetCommandGeneratorShellState() operationError = nil @@ -797,13 +885,21 @@ final class MobileWorkspace: Identifiable { journey.fail(message) return } + guard host.isEnabled(transport) else { + busy = false + connectionFailure = TerminalTransportError.unsupportedRoute.localizedDescription + journey.fail(connectionFailure ?? "") + return + } if descriptor.kind == .telnet { connectTelnet(host: host, token: token, reconnecting: reconnecting) return } - isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled( - globalSetting: terminalSettings.preferences.shellIntegration) - if descriptor.kind == .ssh, pendingCommand == nil, + isShellIntegrationEnabled = + descriptor.kind != .mosh + && host.shellIntegrationMode.isEnabled( + globalSetting: terminalSettings.preferences.shellIntegration) + if resumeCheckpoint == nil, descriptor.kind.isSSHTerminal, pendingCommand == nil, let snippetID = host.startupSnippetID, let body = hosts.snippet(snippetID)?.body, !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -813,12 +909,13 @@ final class MobileWorkspace: Identifiable { ) } let route = connectionRoute?.hosts ?? hosts.route(for: host) + let authenticationRoute = resumeCheckpoint == nil ? route : (transport == .mosh ? [] : Array(route.dropLast())) journey.begin( host: host, route: route, - purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel, + purpose: connectionPurpose, username: usernameHint ?? hosts.identity(for: host)?.username, mode: reconnecting ? .reconnection : .connection) - if host.isTailscaleManaged, submissions[host.id] == nil, usernameHint == nil, + if resumeCheckpoint == nil, host.isTailscaleManaged, submissions[host.id] == nil, usernameHint == nil, hosts.savedTailscaleUsername(for: host) == nil { authenticationRequired = true @@ -831,7 +928,7 @@ final class MobileWorkspace: Identifiable { busy = false return } - if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: route) { + if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: authenticationRoute) { authenticationRequired = true authenticationIsProxy = true authenticationMode = .localIdentity @@ -842,7 +939,7 @@ final class MobileWorkspace: Identifiable { busy = false return } - if let passwordHost = submissions.firstHostRequiringPassword(in: route) { + if let passwordHost = submissions.firstHostRequiringPassword(in: authenticationRoute) { authenticationRequired = true authenticationMode = .localIdentity authenticationHostID = passwordHost.id @@ -855,7 +952,7 @@ final class MobileWorkspace: Identifiable { return } if let passphraseHost = submissions.firstHostRequiringPassphrase( - in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry + in: authenticationRoute, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry ) { authenticationRequired = true authenticationMode = .localIdentity @@ -882,13 +979,21 @@ final class MobileWorkspace: Identifiable { defer { if generation == token { busy = false } } guard generation == token, !Task.isCancelled else { return } do { - let preparation = try hosts.prepareSSHConnection( - for: host, submissions: attemptSubmissions, - proxySubmissions: attemptProxySubmissions, - temporaryHostKeys: temporaryKeys) - let config = try await preparation.resolve( - trustPolicy: .allowChallenge - ) + var resolvedConfiguration: SSHConnectionConfiguration + if resumeCheckpoint != nil, transport == .mosh { + resolvedConfiguration = SSHConnectionConfiguration( + hostID: host.id, host: host.host, port: host.sshPort, + username: resumeUsername ?? "", credentials: [], expectedHostKeys: []) + } else { + let preparation = try hosts.prepareSSHConnection( + for: host, submissions: attemptSubmissions, + proxySubmissions: attemptProxySubmissions, + temporaryHostKeys: temporaryKeys) + resolvedConfiguration = try await preparation.resolve( + discoveryOnly: resumeCheckpoint != nil, trustPolicy: .allowChallenge) + } + if let resumeUsername { resolvedConfiguration.username = resumeUsername } + let config = resolvedConfiguration guard generation == token, !Task.isCancelled else { return } var request = SSHConnectionRequest( configuration: config, @@ -909,7 +1014,7 @@ final class MobileWorkspace: Identifiable { request.agentForwardingContextID = id var externalFile: RemoteFile? - if descriptor.kind == .ssh { + if descriptor.kind.isSSHTerminal { let backend = backend let handlers = startSSHEventConsumer(for: token) let session = connectionServices.makePTYChannel( @@ -917,6 +1022,50 @@ final class MobileWorkspace: Identifiable { handlers.exit, handlers.shellIntegration) ssh = session + if transport.isResumable { + recoveryRecord = TerminalRecoveryRecord( + id: recoveryID, host: host, username: config.username, transport: transport) + recoveryLease = await terminalRecoveryStore.register(recoveryID) + guard generation == token else { throw CancellationError() } + let snapshot = hosts.additionalConnectionSnapshot(from: config) + let port = host.terminalTransports.eternalTerminalPort + let hostKeyAuthorizer = request.authorizeHostKey + let keyboardInteractive = request.keyboardInteractive + let securityKeySigning = request.securityKeySigning + let hostKeyConstraints = request.hostKeyConstraints + let needsControlRoute = transport == .mosh && resumeCheckpoint != nil + let controlUsername = config.username + let requestFactory: @Sendable (Bool) async throws -> SSHConnectionRequest = { + [weak self, weak hosts] authenticatingTarget in + guard let self, let hosts, await self.generation == token else { + throw CancellationError() + } + let selectedSnapshot = + if authenticatingTarget && needsControlRoute { + try await hosts.resumableControlSnapshot(for: host, username: controlUsername) + } else { + snapshot + } + return try await hosts.resumableRouteRequest( + from: selectedSnapshot, authorizeHostKey: hostKeyAuthorizer, + keyboardInteractive: keyboardInteractive, securityKeySigning: securityKeySigning, + hostKeyConstraints: hostKeyConstraints, authenticatingTarget: authenticatingTarget) + } + await session.configureTransport( + transport, options: host.terminalTransports, checkpoint: resumeCheckpoint, + route: { + try await TerminalTransportRoute.open(using: requestFactory(false), port: port) + }, + control: { command in + try await TerminalTransportRoute.execute(command, using: requestFactory(true)) + }, + stateChanged: { [weak self] state in + Task { @MainActor in + guard let self, self.generation == token else { return } + self.receiveTransportState(state) + } + }) + } await session.configureEnvironment(host.environmentVariables) { variables in let list = variables.joined(separator: ", ") let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list)) @@ -955,6 +1104,11 @@ final class MobileWorkspace: Identifiable { UInt16(clamping: viewport.rows), progressHandler ) + if resumeCheckpoint != nil { + pendingCommand = nil + pendingConnectionStartupCommand = nil + resumeCheckpoint = nil + } } else { let client = try await connectionServices.connectSFTP( request, progressHandler, @@ -1035,7 +1189,7 @@ final class MobileWorkspace: Identifiable { proxySubmissions.removeAll() let wasReconnecting = connection.markConnected() connected = true - if let ssh, sessionPersistence.attachment == nil { + if descriptor.kind == .ssh, let ssh, sessionPersistence.attachment == nil { commandHistory.load( using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil) } @@ -1048,7 +1202,7 @@ final class MobileWorkspace: Identifiable { if wasReconnecting, descriptor.kind == .ssh, sessionPersistence.attachment == nil { appendTerminalNotice(String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted)) } - if descriptor.kind == .ssh { + if descriptor.kind.isSSHTerminal { if !isShellIntegrationEnabled { isInitialCommandSubmissionReady = true } @@ -1060,6 +1214,12 @@ final class MobileWorkspace: Identifiable { } } catch { guard generation == token else { return } + if resumeCheckpoint != nil, ssh != nil { + // Once a restored engine could have transmitted, never reuse its saved counters. + resumeCheckpoint = nil + requiresTransportRecovery = true + transportState = .failed + } stopSSHEventConsumer() connectionFailure = error is CancellationError @@ -1242,7 +1402,7 @@ final class MobileWorkspace: Identifiable { } private func submitPendingInitialCommandIfReady() { - guard descriptor.kind == .ssh, connected, isInitialCommandSubmissionReady, + guard descriptor.kind.isSSHTerminal, connected, isInitialCommandSubmissionReady, let session = ssh, let command = consumePendingInitialCommand() else { return } let token = generation @@ -1254,6 +1414,14 @@ final class MobileWorkspace: Identifiable { } func retryReconnectNow() { + if transportSuspension != nil { + resumeAfterBackground() + return + } + if transport.isResumable, let ssh { + Task { try? await ssh.retryTransport() } + return + } connection.reconnect.retryNow() } @@ -1289,6 +1457,10 @@ final class MobileWorkspace: Identifiable { } func suspendForBackgroundExpiration() { + if transport.isResumable, ssh != nil { + suspendResumableTransport() + return + } guard connectionResumeIntent != .foreground, hasActiveConnectionFlow else { return } cancelPendingUpload() stopSSHEventConsumer() @@ -1333,9 +1505,67 @@ final class MobileWorkspace: Identifiable { func resumeAfterBackground() { guard connectionResumeIntent == .foreground else { return } connectionResumeIntent = .none + if transport.isResumable, let ssh { + let token = generation + let suspension = transportSuspension + Task { [weak self] in + await suspension?.value + guard let self, self.generation == token else { return } + do { + try await self.terminalRecoveryStore.invalidate(self.recoveryID) + guard self.generation == token else { return } + try await ssh.resumeTransport() + self.transportSuspension = nil + } catch { + self.receiveTransportState(.failed) + } + } + return + } connect() } + func suspendResumableTransport() { + guard transport.isResumable, transportSuspension == nil, let ssh, + let record = recoveryRecord, let lease = recoveryLease + else { return } + connectionResumeIntent = .foreground + let token = generation + transportSuspension = Task { [weak self] in + do { + let checkpoint = try await ssh.suspendTransport() + guard let self, self.generation == token, self.ssh === ssh else { return } + try await self.terminalRecoveryStore.save(record, checkpoint: checkpoint, lease: lease) + } catch { + guard let self, self.generation == token else { return } + self.operationError = error.localizedDescription + } + } + } + + private func receiveTransportState(_ state: ResumableTerminalState) { + transportState = state + switch state { + case .connected: + connected = true + busy = false + status = String(localized: AppStrings.Connections.connected) + journey.hide() + case .recovering, .suspended: + connected = false + status = String(localized: AppStrings.Connections.reconnecting) + case .failed, .ended: + connected = false + busy = false + connection.resetRecovery() + connectionFailure = TerminalTransportError.sessionFailed.localizedDescription + status = connectionFailure ?? "" + let recoveryID = recoveryID + Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) } + case .connecting, .closed: break + } + } + func restoreRemoteSession(_ reference: RemoteSessionReference?) { sessionPersistence.attachment = reference activitySession.recovery?.remoteSession = reference.flatMap { try? JSONEncoder().encode($0) } @@ -1427,6 +1657,11 @@ final class MobileWorkspace: Identifiable { } private func handleConnectionFailure(_ message: String) { + if transport.isResumable { + connectionFailure = message + receiveTransportState(.failed) + return + } guard connection.hasConnectedOnce else { return } scheduleReconnect(after: message) } @@ -1452,7 +1687,7 @@ final class MobileWorkspace: Identifiable { connected = false transferSnapshot = nil status = String(localized: AppStrings.Connections.reconnecting) - if announcesLoss, descriptor.kind == .ssh { + if announcesLoss, descriptor.kind.isSSHTerminal { appendTerminalNotice(String(localized: AppStrings.Connections.connectionLostReconnectingProgress)) } if let host = connectionHost(descriptor.hostID) { @@ -1464,7 +1699,7 @@ final class MobileWorkspace: Identifiable { } else { journey.begin( host: host, route: connectionRoute?.hosts ?? hosts.route(for: host), - purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel, + purpose: connectionPurpose, username: usernameHint ?? hosts.identity(for: host)?.username, mode: .reconnection) } } @@ -1515,6 +1750,13 @@ final class MobileWorkspace: Identifiable { } func disconnect() { + if transport.isResumable { + let recoveryID = recoveryID + Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) } + recoveryLease = nil + transportSuspension?.cancel() + transportSuspension = nil + } sessionPersistence.cancel() commandHistory.cancelLoading() commandMonitor.endConnection() @@ -1647,15 +1889,9 @@ final class MobileWorkspace: Identifiable { journey.fail(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)) return } - let purpose: ConnectionPurpose = - switch descriptor.kind { - case .ssh: .sshSession - case .telnet: .telnetSession - case .sftp: .sftpChannel - } journey.begin( host: host, route: hosts.route(for: host), - purpose: purpose, + purpose: connectionPurpose, username: hosts.identity(for: host)?.username, mode: mode) } @@ -2009,7 +2245,7 @@ final class MobileWorkspace: Identifiable { } func reportUploadFailure(_ message: String) { - if descriptor.kind == .ssh { + if descriptor.kind.isSSHTerminal { showUploadNotice(.rejected(message)) } else { uploadNotice = nil diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index a4ad612b..b90afe78 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -313,7 +313,7 @@ struct MobileWorkspaceListView: View { private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { switch kind { - case .ssh: .accentColor + case .ssh, .mosh, .eternalTerminal: .accentColor case .telnet: .purple case .sftp: .orange } diff --git a/TFI/Features/Workspaces/MobileWorkspaceSearchModifier.swift b/TFI/Features/Workspaces/MobileWorkspaceSearchModifier.swift index 03741ad0..77dfb384 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceSearchModifier.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceSearchModifier.swift @@ -7,7 +7,7 @@ struct MobileWorkspaceSearchModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { switch workspace.descriptor.kind { - case .ssh, .telnet: + case .ssh, .mosh, .eternalTerminal, .telnet: content.mobileToolbarSearch( text: Binding(get: { workspace.find.query }, set: { workspace.find.updateQuery($0) }), isPresented: Binding( diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 04fda073..df8fa9e5 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -116,6 +116,11 @@ struct MobileWorkspaceView: View { MobileRegionReader { regions in MobilePaneLayout(frames: [regions.continuous]) { workspaceContent + .overlay(alignment: .top) { + TerminalTransportStatusView( + state: workspace.transportState, retry: workspace.retryReconnectNow, + close: { closeWorkspace(workspace.id) }) + } .overlay(alignment: .bottom) { VStack(spacing: 12) { if showsCommandGeneratorOverlay { @@ -183,7 +188,7 @@ struct MobileWorkspaceView: View { @ToolbarContentBuilder private var workspaceToolbar: some ToolbarContent { - if workspace.descriptor.kind == .ssh { + if workspace.descriptor.kind.isSSHTerminal { MobileToolbarOverflow(placement: .topBarTrailing) { Button(AppStrings.Workspace.generateCommand.swiftUIKey, systemImage: "sparkles") { workspace.generateCommand(using: commandGenerator) @@ -731,7 +736,7 @@ struct MobileWorkspaceView: View { } private var operationAlertTitle: String { - workspace.descriptor.kind == .ssh + workspace.descriptor.kind.isSSHTerminal ? String(localized: AppStrings.Workspace.uploadFailed) : String(localized: AppStrings.SFTP.sftpError) } @@ -759,7 +764,7 @@ struct MobileWorkspaceView: View { private var sameDirectoryMenuAction: some View { if let launch = workspace.sameDirectoryLaunch { switch launch.kind { - case .ssh: + case .ssh, .mosh, .eternalTerminal: Button(AppStrings.Connections.openWithSsh.swiftUIKey, systemImage: "terminal") { openVerifiedDirectory(launch) } @@ -785,7 +790,7 @@ struct MobileWorkspaceView: View { private var isSFTP: Bool { workspace.descriptor.kind == .sftp } private var showsCommandGeneratorOverlay: Bool { - workspace.descriptor.kind == .ssh && workspace.commandGeneratorState != .idle + workspace.descriptor.kind.isSSHTerminal && workspace.commandGeneratorState != .idle } private var connectionJourney: some View { diff --git a/TFI/Infrastructure/MobileWorkspaceStore.swift b/TFI/Infrastructure/MobileWorkspaceStore.swift index f524e899..b4db7f03 100644 --- a/TFI/Infrastructure/MobileWorkspaceStore.swift +++ b/TFI/Infrastructure/MobileWorkspaceStore.swift @@ -2,9 +2,48 @@ import Foundation /// Retire local workspace snapshots without migrating them into a new cache. @MainActor -enum MobileWorkspaceStore { +final class MobileWorkspaceStore { static let key = "mobile.workspaces.v1" static let recentlyClosedKey = "mobile.recentlyClosed.v2" + static let restorationKey = "mobile.restorableWorkspaces.v1" + + struct Snapshot: Codable, Equatable { + var version = 1 + var sessions: [String: [MobileWorkspaceDescriptor]] = [:] + } + + private let defaults: UserDefaults? + private var canSave = true + + init(defaults: UserDefaults?) { + self.defaults = defaults + } + + func load() throws -> Snapshot { + do { + guard let value = defaults?.object(forKey: Self.restorationKey) else { return Snapshot() } + guard let data = value as? Data else { throw CocoaError(.coderReadCorrupt) } + let snapshot = try JSONDecoder().decode(Snapshot.self, from: data) + try validate(snapshot) + return snapshot + } catch { + canSave = false + throw error + } + } + + func save(_ snapshot: Snapshot) throws { + guard canSave else { throw CocoaError(.coderReadCorrupt) } + try validate(snapshot) + defaults?.set(try JSONEncoder().encode(snapshot), forKey: Self.restorationKey) + } + + private func validate(_ snapshot: Snapshot) throws { + let descriptors = snapshot.sessions.values.flatMap { $0 } + guard snapshot.version == 1, snapshot.sessions.keys.allSatisfy({ !$0.isEmpty }), + Set(descriptors.map(\.id)).count == descriptors.count + else { throw CocoaError(.coderReadCorrupt) } + } static func retire(in defaults: UserDefaults) { defaults.removeObject(forKey: key) diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index 1229462d..d0b17957 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -89,8 +89,8 @@ struct MobileConnectionPickerTests { } @Test func recentConnectionActionsFollowEveryHostProtocolCombination() { - #expect(MobileConnectionHistoryActionPolicy.menuKinds == [.ssh, .telnet, .sftp]) - #expect(MobileConnectionHistoryActionPolicy.swipeKinds == [.sftp, .telnet, .ssh]) + #expect(MobileConnectionHistoryActionPolicy.menuKinds == [.ssh, .mosh, .eternalTerminal, .telnet, .sftp]) + #expect(MobileConnectionHistoryActionPolicy.swipeKinds == [.sftp, .telnet, .eternalTerminal, .mosh, .ssh]) var host = Host(name: "Remote", host: "remote.example.invalid") assertAvailability(host, ssh: true, telnet: false, sftp: true) @@ -107,6 +107,14 @@ struct MobileConnectionPickerTests { assertAvailability(host, ssh: true, telnet: true, sftp: true) #expect(MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host) == [.ssh, .telnet, .sftp]) #expect(MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host) == [.sftp, .telnet, .ssh]) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + #expect( + MobileConnectionHistoryActionPolicy.availableMenuKinds(for: host) + == [.ssh, .mosh, .eternalTerminal, .telnet, .sftp]) + #expect( + MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: host) + == [.sftp, .telnet, .eternalTerminal, .mosh, .ssh]) host.sshEnabled = false host.telnetEnabled = false diff --git a/TFITests/MobileSnippetTests.swift b/TFITests/MobileSnippetTests.swift index 7d1ec769..cc3a4a4d 100644 --- a/TFITests/MobileSnippetTests.swift +++ b/TFITests/MobileSnippetTests.swift @@ -130,8 +130,12 @@ struct MobileSnippetTests { @Test func executionCreatesIndependentWorkspacesInVaultOrderWithoutChangingTargets() throws { let app = try MobileAppModel(inMemory: true) - let first = Host(name: "First", host: "first.example.invalid") - let second = Host(name: "Second", host: "second.example.invalid") + var first = Host(name: "First", host: "first.example.invalid") + first.terminalTransports.moshEnabled = true + first.terminalTransports.defaultTransport = .mosh + var second = Host(name: "Second", host: "second.example.invalid") + second.terminalTransports.eternalTerminalEnabled = true + second.terminalTransports.defaultTransport = .eternalTerminal app.library.upsert(first) app.library.upsert(second) let snippet = Snippet(title: "Status", body: "uptime", targetHostIDs: [first.id]) @@ -142,13 +146,14 @@ struct MobileSnippetTests { #expect(opened.count == 2) #expect(app.workspaces.suffix(2).map(\.descriptor.hostID) == [first.id, second.id]) - #expect(app.workspaces.suffix(2).allSatisfy { $0.descriptor.kind == .ssh }) + #expect(app.workspaces.suffix(2).map(\.descriptor.kind) == [.mosh, .eternalTerminal]) #expect(app.workspaces.suffix(2).allSatisfy { $0.pendingInitialCommand == snippet.body }) #expect(app.snippets.snippets.first?.targetHostIDs == [first.id]) let connectionHistory = app.connectionHistory.entries() #expect(connectionHistory.count == 2) #expect(Set(connectionHistory.map(\.hostID)) == Set([first.id, second.id])) - #expect(connectionHistory.allSatisfy { $0.kind == .ssh }) + #expect(connectionHistory.first { $0.hostID == first.id }?.kind == .mosh) + #expect(connectionHistory.first { $0.hostID == second.id }?.kind == .eternalTerminal) #expect(!app.pro.isPaywallPresented) } diff --git a/TFITests/MobileTerminalRestorationTests.swift b/TFITests/MobileTerminalRestorationTests.swift new file mode 100644 index 00000000..7a8490e5 --- /dev/null +++ b/TFITests/MobileTerminalRestorationTests.swift @@ -0,0 +1,224 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +@testable import TermindIOS + +@Suite @MainActor +struct MobileTerminalRestorationTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func savedTmuxReferenceSurvivesTransportRecoveryAndMissingCheckpoint(_ transport: HostTerminalProtocol) async throws + { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let seed = try MobileAppModel(inMemory: true) + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.sessionPersistence.enabled = true + seed.library.upsert(host) + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: nil) + let descriptor = MobileWorkspaceDescriptor( + hostID: host.id, title: "tmux", kind: .terminal(transport), remoteSession: reference) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["scene": [descriptor]])) + var dependencies = MobileAppDependencies.testing(defaults: fixture.defaults) + dependencies.container = seed.container + dependencies.terminalRecoveryStore = fixture.recovery + let app = try MobileAppModel(dependencies: dependencies) + let workspace = try #require(app.workspaceSession(for: "scene").openWorkspace(descriptor.id)) + defer { workspace.disconnect() } + #expect(workspace.sessionPersistence.attachment == reference) + #expect(workspace.requiresTransportRecovery) + + workspace.connect() + + #expect(await eventually { workspace.journey.phase == .waiting }) + #expect(!workspace.requiresTransportRecovery) + #expect(workspace.sessionPersistence.attachment == reference) + #expect(workspace.sessionPersistence.requiresSelection) + #expect(workspace.pendingInitialCommand == nil) + #expect(workspace.journey.purpose == .terminal(transport)) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func connectionJourneyUsesTheSelectedTransportInsteadOfSSH(_ transport: HostTerminalProtocol) async { + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + let store = HostLibraryModel(hosts: [host]) + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)), hosts: store) + defer { workspace.disconnect() } + + workspace.connect() + + #expect(workspace.journey.purpose == .terminal(transport)) + #expect(workspace.journey.plainTextLog.contains("Starting \(transport.displayName) session")) + #expect(await eventually { workspace.journey.phase == .waiting }) + } + + @Test func coldLaunchKeepsResumableAndTmuxWorkspacesInTheirOriginalListsWithoutConnecting() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let seed = try MobileAppModel(inMemory: true) + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.sessionPersistence.enabled = true + seed.library.upsert(host) + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: nil) + let mosh = MobileWorkspaceDescriptor(hostID: host.id, title: "Mosh", kind: .mosh) + let et = MobileWorkspaceDescriptor(hostID: host.id, title: "ET", kind: .eternalTerminal) + let tmux = MobileWorkspaceDescriptor( + hostID: host.id, title: "tmux", kind: .ssh, remoteSession: reference) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["one": [mosh, tmux], "two": [et]])) + var connections = 0 + var dependencies = MobileAppDependencies.testing(defaults: fixture.defaults) + dependencies.container = seed.container + dependencies.terminalRecoveryStore = fixture.recovery + dependencies.workspaceConnections.connectSSH = { _, _, _, _, _, _ in connections += 1 } + let app = try MobileAppModel(dependencies: dependencies) + #expect(app.workspaceSession(for: "one").savedWorkspaceDescriptors == [mosh, tmux]) + #expect(app.workspaceSession(for: "two").savedWorkspaceDescriptors == [et]) + #expect(app.workspaces.isEmpty) + #expect(connections == 0) + app.workspaceSession(for: "one").close(mosh.id, rememberForReopen: false) + let saved = try MobileWorkspaceStore(defaults: fixture.defaults).load() + #expect(saved.sessions["one"] == [tmux]) + #expect(saved.sessions["two"] == [et]) + let workspace = try #require(app.workspaceSession(for: "one").openWorkspace(tmux.id)) + #expect(workspace.sessionPersistence.attachment == reference) + #expect(workspace.sessionPersistence.requiresSelection) + workspace.disconnect() + } + + @Test func missingCheckpointNeverBootstrapsANewShellEvenAfterRepeatedSelection() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let seed = try MobileAppModel(inMemory: true) + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + seed.library.upsert(host) + let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Old shell", kind: .mosh) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["scene": [descriptor]])) + var connections = 0 + var dependencies = MobileAppDependencies.testing(defaults: fixture.defaults) + dependencies.container = seed.container + dependencies.terminalRecoveryStore = fixture.recovery + dependencies.workspaceConnections.connectSSH = { _, _, _, _, _, _ in connections += 1 } + let app = try MobileAppModel(dependencies: dependencies) + let scene = MobileSceneModel(app: app, sessionID: "scene") + scene.selectedDestination = .workspaces + scene.open(descriptor.destinationURL) + let workspace = try #require(scene.activeWorkspace) + #expect(await eventually { workspace.transportState == .failed }) + scene.open(descriptor.destinationURL) + workspace.connect() + #expect(await eventually { workspace.transportState == .failed }) + #expect(app.workspaces.count == 1) + #expect(workspace.requiresTransportRecovery) + #expect(connections == 0) + scene.workspaceSession.closeAll() + scene.open(descriptor.destinationURL) + #expect(scene.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) + } + + @Test func activityRestoresTheSavedMoshOnceWithoutTargetSSHCredentialsOrStartupReplay() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let seed = try MobileAppModel(inMemory: true) + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + seed.library.upsert(host) + let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Old shell", kind: .mosh) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["scene": [descriptor]])) + let record = TerminalRecoveryRecord( + id: descriptor.id, host: host, username: "original-user", transport: .mosh) + let lease = await fixture.recovery.register(record.id) + try await fixture.recovery.save( + record, checkpoint: .init(transport: .mosh, payload: Data("opaque".utf8)), lease: lease) + var connections = 0 + var writes: [Data] = [] + var dependencies = MobileAppDependencies.testing(defaults: fixture.defaults) + dependencies.container = seed.container + dependencies.terminalRecoveryStore = fixture.recovery + dependencies.workspaceConnections.connectSSH = { _, request, _, _, _, _ in + connections += 1 + #expect(request.configuration.username == "original-user") + #expect(request.configuration.credentials.isEmpty) + #expect(request.configuration.jumps.isEmpty) + } + dependencies.workspaceConnections.writeSSH = { _, data in writes.append(data) } + let app = try MobileAppModel(dependencies: dependencies) + let scene = MobileSceneModel(app: app, sessionID: "scene") + scene.selectedDestination = .workspaces + let card = WorkspaceActivityAttributes.Session( + id: descriptor.id, title: descriptor.title, kind: "mosh", connected: false, + recovery: .init(sourceWorkspaceID: descriptor.id, hostID: host.id, kind: "mosh")) + scene.open(card.destinationURL) + scene.open(card.destinationURL) + #expect(await eventually { scene.activeWorkspace?.connected == true }) + #expect(app.workspaces.count == 1) + #expect(scene.activeWorkspace?.id == descriptor.id) + #expect(connections == 1) + #expect(writes.isEmpty) + #expect(try await fixture.recovery.records().isEmpty) + scene.workspaceSession.closeAll() + scene.open(card.destinationURL) + #expect(scene.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) + } + + @Test func unknownSnapshotVersionIsNotOverwritten() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let data = Data("{\"version\":99,\"sessions\":{}}".utf8) + fixture.defaults.set(data, forKey: MobileWorkspaceStore.restorationKey) + let store = MobileWorkspaceStore(defaults: fixture.defaults) + #expect(throws: (any Error).self) { try store.load() } + #expect(throws: (any Error).self) { try store.save(.init()) } + #expect(fixture.defaults.data(forKey: MobileWorkspaceStore.restorationKey) == data) + } + + @Test func restorationWaitsForHostDiscoveryWithoutDeletingItsDescriptor() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Tailnet", kind: .eternalTerminal) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["scene": [descriptor]])) + let app = try MobileAppModel(dependencies: .testing(defaults: fixture.defaults)) + let session = app.workspaceSession(for: "scene") + #expect(session.openWorkspace(descriptor.id) == nil) + app.persistWorkspaceDescriptors() + #expect(try MobileWorkspaceStore(defaults: fixture.defaults).load().sessions["scene"] == [descriptor]) + } + + private func eventually(_ condition: () -> Bool) async -> Bool { + for _ in 0..<100 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + private struct Fixture { + let name = "TerminalRestoration.\(UUID())" + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let defaults: UserDefaults + let recovery: TerminalRecoveryStore + init() throws { + defaults = try #require(UserDefaults(suiteName: name)) + recovery = TerminalRecoveryStore(directory: directory, testingKey: Data(repeating: 9, count: 32)) + } + func cleanUp() { + defaults.removePersistentDomain(forName: name) + try? FileManager.default.removeItem(at: directory) + } + } +} diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 011d8d82..549b9f8d 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -93,7 +93,7 @@ struct MobileVaultFeatureTests { @Test func hostMenuSeparatesConnectionEditingAndDeletionActions() { #expect( MobileHostMenuPolicy.sections == [ - [.ssh, .telnet, .sftp], + [.ssh, .mosh, .eternalTerminal, .telnet, .sftp], [.tailscale], [.edit, .duplicate], [.delete], @@ -117,6 +117,8 @@ struct MobileVaultFeatureTests { ]) host.sshEnabled = true + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true #expect(MobileHostMenuPolicy.sections(for: host, hasTailscalePeer: true) == MobileHostMenuPolicy.sections) host.sshEnabled = false diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 065183b8..fe9a4e02 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -30,6 +30,8 @@ nonisolated enum BatchLayout: Hashable, Sendable { nonisolated enum HostOpenTarget: Hashable, Sendable { case preferredTerminal case sshSession + case moshSession + case eternalTerminalSession case telnetSession case sftpChannel } @@ -1043,10 +1045,14 @@ final class AppModel { 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) } @@ -1170,7 +1176,7 @@ final class AppModel { /// thing that should differ between them. private func beginOpening(_ host: Host, as target: HostOpenTarget) { switch target { - case .preferredTerminal, .sshSession, .telnetSession: + case .preferredTerminal, .sshSession, .moshSession, .eternalTerminalSession, .telnetSession: hosts.setStatus(.connecting, for: host.id) case .sftpChannel: break @@ -1245,7 +1251,7 @@ final class AppModel { let targets = hosts.hosts(matching: "", for: .snippetExecution) .filter { wanted.contains($0.id) } guard !targets.isEmpty else { continue } - for host in targets { beginOpening(host, as: .sshSession) } + for host in targets { beginOpening(host, as: .preferredTerminal) } if let workspace = window.openSnippetWorkspace( hosts: targets, command: snippet.body ) { diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index 60129d68..2986fff5 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -326,6 +326,8 @@ struct TermindCommands: Commands { let target: HostOpenTarget = switch row.entry.kind { case .ssh: .sshSession + case .mosh: .moshSession + case .eternalTerminal: .eternalTerminalSession case .telnet: .telnetSession case .sftp: .sftpChannel } diff --git a/TFM/DesignSystem/MenuSymbol.swift b/TFM/DesignSystem/MenuSymbol.swift index 98fd2627..c84751ec 100644 --- a/TFM/DesignSystem/MenuSymbol.swift +++ b/TFM/DesignSystem/MenuSymbol.swift @@ -1,4 +1,5 @@ import Foundation +import TermindDomain /// One glyph per menu verb. /// @@ -33,6 +34,8 @@ nonisolated enum MenuSymbol { static let connect = "network" static let batchStartup = "wind" static let ssh = "apple.terminal" + static let mosh = HostTerminalProtocol.mosh.symbol + static let eternalTerminal = HostTerminalProtocol.eternalTerminal.symbol static let telnet = "text.rectangle" static let sftp = "folder" diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 778abed4..f651add1 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -154,6 +154,7 @@ struct HostEditorView: View { HostConnectionFields(host: $draft, protocol: selectedProtocol).disabled(draft.isTailscaleManaged) if selectedProtocol == .ssh && draft.sshEnabled { + HostTransportSection(host: $draft) if draft.isTailscaleManaged { Section(AppStrings.Vault.authentication.swiftUIKey) { TextField(AppStrings.Vault.username.swiftUIKey, text: $tailscaleUsername) @@ -655,7 +656,7 @@ struct HostEditorView: View { private var isValid: Bool { !draft.host.trimmingCharacters(in: .whitespaces).isEmpty && draft.preferredTerminalProtocol != nil - && (!draft.sshEnabled || (1...65_535).contains(draft.sshPort)) + && (!draft.sshEnabled || (draft.terminalTransports.isValid && (1...65_535).contains(draft.sshPort))) && (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort)) && customIdentity.isProxyValid(for: draft) && (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft)) diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index d44212a4..54d73f39 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -86,6 +86,8 @@ enum HostMenuPolicy { enum HostConnectAction: CaseIterable, Hashable { case ssh + case mosh + case eternalTerminal case telnet case sftp @@ -100,6 +102,8 @@ enum HostConnectAction: CaseIterable, Hashable { var target: HostOpenTarget { switch self { case .ssh: .sshSession + case .mosh: .moshSession + case .eternalTerminal: .eternalTerminalSession case .telnet: .telnetSession case .sftp: .sftpChannel } @@ -108,6 +112,8 @@ enum HostConnectAction: CaseIterable, Hashable { var title: LocalizedStringResource { switch self { case .ssh: AppStrings.Vault.withSsh + case .mosh: AppStrings.Transports.withMosh + case .eternalTerminal: AppStrings.Transports.withEternalTerminal case .telnet: AppStrings.Vault.withTelnet case .sftp: AppStrings.Vault.withSftp } @@ -116,6 +122,8 @@ enum HostConnectAction: CaseIterable, Hashable { var symbol: String { switch self { case .ssh: MenuSymbol.ssh + case .mosh: MenuSymbol.mosh + case .eternalTerminal: MenuSymbol.eternalTerminal case .telnet: MenuSymbol.telnet case .sftp: MenuSymbol.sftp } @@ -125,6 +133,8 @@ enum HostConnectAction: CaseIterable, Hashable { switch self { case .ssh, .sftp: host.sshEnabled case .telnet: host.telnetEnabled + case .mosh: host.isEnabled(.mosh) + case .eternalTerminal: host.isEnabled(.eternalTerminal) } } } diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index bc7ab677..5feb45ab 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -1232,6 +1232,8 @@ private struct ConnectionHistorySidebar: View { let target: HostOpenTarget = switch entry.kind { case .ssh: .sshSession + case .mosh: .moshSession + case .eternalTerminal: .eternalTerminalSession case .telnet: .telnetSession case .sftp: .sftpChannel } diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 2b962ab1..749474fe 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -36,6 +36,8 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// worse than shipping none — so a sshSession is always a sshSession *on* a host. var hostID: Host.ID var title: String + var transport: HostTerminalProtocol = .ssh + private(set) var transportState: ResumableTerminalState? var status: ConnectionStatus = .disconnected { didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } @@ -428,6 +430,10 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } func connect(using store: any RemoteSessionHostStore) { + if transport.isResumable, let ssh { + Task { try? await ssh.retryTransport() } + return + } startsOnMount = false startConnection(using: store) } @@ -452,8 +458,16 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { connectionJourney.fail(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)) return } - isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled( - globalSetting: DesktopSettingsKey.isShellIntegrationEnabled) + guard host.isEnabled(transport) else { + startsOnMount = false + status = .failed(TerminalTransportError.unsupportedRoute.localizedDescription) + connectionJourney.fail(TerminalTransportError.unsupportedRoute.localizedDescription) + return + } + isShellIntegrationEnabled = + transport != .mosh + && host.shellIntegrationMode.isEnabled( + globalSetting: DesktopSettingsKey.isShellIntegrationEnabled) if pendingInitialCommand == nil, let snippetID = host.startupSnippetID, let body = store.snippet(snippetID)?.body, @@ -472,7 +486,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { ?? (host.isTailscaleManaged ? store.savedTailscaleUsername(for: host) : identity?.username) ?? usernameHint connectionJourney.begin( - host: host, route: route, purpose: .sshSession, username: username, + host: host, route: route, purpose: .terminal(transport), username: username, mode: reconnecting ? .reconnection : .connection ) if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) { @@ -804,6 +818,10 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } func retryReconnectNow() { + if transport.isResumable, let ssh { + Task { try? await ssh.retryTransport() } + return + } reconnectController.retryNow() } @@ -859,6 +877,46 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { ) ssh = actor do { + if transport.isResumable { + let snapshot = store.additionalConnectionSnapshot(from: configuration) + let port = host.terminalTransports.eternalTerminalPort + let requestFactory: @Sendable (Bool) async throws -> SSHConnectionRequest = { + [weak self, weak store] authenticatingTarget in + guard let self, let store, await self.connectionAttemptID == attemptID else { + throw CancellationError() + } + return try await store.resumableRouteRequest( + from: snapshot, authorizeHostKey: authorizeHostKey, + keyboardInteractive: { [weak self] challenge in + guard let self, await self.connectionAttemptID == attemptID else { + throw CancellationError() + } + return try await self.connection.answerKeyboardInteractive(challenge) + }, + securityKeySigning: { [weak self] request in + guard let self, await self.connectionAttemptID == attemptID else { + throw CancellationError() + } + return try await self.connection.signWithSecurityKey(request) + }, + hostKeyConstraints: self.connectionOrigin.hostKeyConstraints, + authenticatingTarget: authenticatingTarget) + } + await actor.configureTransport( + transport, options: host.terminalTransports, + route: { + try await TerminalTransportRoute.open(using: requestFactory(false), port: port) + }, + control: { command in + try await TerminalTransportRoute.execute(command, using: requestFactory(true)) + }, + stateChanged: { [weak self] state in + Task { @MainActor in + guard let self, self.connectionAttemptID == attemptID else { return } + self.receiveTransportState(state) + } + }) + } await actor.configureEnvironment(host.environmentVariables) { variables in let list = variables.joined(separator: ", ") let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list)) @@ -910,9 +968,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { temporaryHostKeys.removeAll() _ = connection.markConnected() status = .connected - if sessionPersistence.attachment == nil { + if transport == .ssh && sessionPersistence.attachment == nil { commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil) - } else { + } else if sessionPersistence.attachment != nil { pendingInitialCommand = nil pendingConnectionStartupCommand = nil homeResolution = .unavailable @@ -922,7 +980,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } else { connectionJourney.hide() } - if wasReconnecting, sessionPersistence.attachment == nil { + if wasReconnecting, transport == .ssh, sessionPersistence.attachment == nil { appendTerminalNotice( String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted) ) @@ -938,6 +996,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } } + private func receiveTransportState(_ state: ResumableTerminalState) { + transportState = state + switch state { + case .connected: + status = .connected + connectionJourney.hide() + case .recovering: + status = .reconnecting + case .failed, .ended: + connection.resetRecovery() + status = .failed(TerminalTransportError.sessionFailed.localizedDescription) + case .suspended: status = .reconnecting + case .connecting, .closed: break + } + } + /// A normal remote-shell exit owns the pane lifecycle, not the reusable /// Ghostty surface lifecycle. Marking the in-memory backend as finished /// makes the surface permanently stop producing input, which is incorrect @@ -1026,6 +1100,12 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { _ error: any Error, store: any RemoteSessionHostStore, reconnecting: Bool ) { connectionAttemptID = nil + if transport.isResumable { + connection.resetRecovery() + status = .failed(error.localizedDescription) + connectionJourney.fail(error.localizedDescription) + return + } if connection.hasConnectedOnce || reconnecting { scheduleReconnect(after: error.localizedDescription, using: store) } else { @@ -1048,7 +1128,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) { connection.scheduleReconnect( after: message, hostID: hostID, using: store, - purpose: .sshSession, route: connectionRoute, usernameHint: usernameHint + purpose: .terminal(transport), route: connectionRoute, usernameHint: usernameHint ) { resetCommandGeneratorShellState() connectionAttemptID = nil @@ -1157,6 +1237,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { func copyForReconnect() -> SSHSessionModel { let host = transientHost ?? Host(id: hostID, name: title, host: title) let copy = SSHSessionModel(host: host, usernameHint: usernameHint) + copy.transport = transport copy.sessionPersistence.attachment = sessionPersistence.attachment copy.connectionRoute = connectionRoute copy.connectionOrigin = connectionOrigin diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 7d989f85..6be66a13 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -48,6 +48,10 @@ struct TerminalPlaceholderView: View { .desktopTerminalChrome(state: sshSession.terminalState) .overlay(alignment: .bottom) { uploadDecoration } .overlay { connectionJourney } + .overlay(alignment: .top) { + TerminalTransportStatusView( + state: sshSession.transportState, retry: sshSession.retryReconnectNow, close: closePane) + } .animation(Motion.panel, value: uploads.notice) .onAppear { // The mounted view is the final source of truth after any drag, diff --git a/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift index 745a0cd6..9d258769 100644 --- a/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift +++ b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift @@ -35,6 +35,8 @@ struct ClosedWorkspaceRecord: Codable, Identifiable { case .empty: true case .ssh(let id), .sftp(let id): hosts.host(id)?.sshEnabled == true case .telnet(let id): hosts.host(id)?.telnetEnabled == true + case .mosh(let id): hosts.host(id)?.isEnabled(.mosh) == true + case .eternalTerminal(let id): hosts.host(id)?.isEnabled(.eternalTerminal) == true } } } diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index 18b229a7..f2ad5ab5 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -157,7 +157,7 @@ final class Pane: Identifiable { var symbol: String { switch content { - case .sshSession: "apple.terminal" + case .sshSession(let session): session.transport.symbol case .telnetSession: MenuSymbol.telnet case .sftpChannel: "folder" case .serialSession: "cable.connector" @@ -402,22 +402,23 @@ extension Pane { /// Workspace — and a second copy of this is a second chance to forget the /// status flip. static func sshSession( - host: Host, usernameHint: String? = nil, initialCommand: String? = nil + host: Host, usernameHint: String? = nil, initialCommand: String? = nil, transport: HostTerminalProtocol = .ssh ) -> Pane { sshSession( host: host, usernameHint: usernameHint, initialCommand: initialCommand, - recordsInitialCommandInHistory: true + recordsInitialCommandInHistory: true, transport: transport ) } static func sshSession( host: Host, usernameHint: String?, initialCommand: String?, - recordsInitialCommandInHistory: Bool + recordsInitialCommandInHistory: Bool, transport: HostTerminalProtocol = .ssh ) -> Pane { let sshSession = SSHSessionModel( host: host, usernameHint: usernameHint, initialCommand: initialCommand, recordsInitialCommandInHistory: recordsInitialCommandInHistory ) + sshSession.transport = transport sshSession.status = .connecting return Pane(.sshSession(sshSession)) } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index d00b6592..61db204e 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -920,7 +920,7 @@ final class WindowModel { @discardableResult func openSSHSession( - host: Host, + host: Host, transport: HostTerminalProtocol = .ssh, usernameHint: String? = nil, connectionRoute: SSHConnectionRoute? = nil, initialPath: String? = nil, @@ -930,7 +930,7 @@ final class WindowModel { host: host, usernameHint: usernameHint, initialCommand: initialPath.flatMap(RemoteShellCommand.changeDirectory), - recordsInitialCommandInHistory: initialPath == nil + recordsInitialCommandInHistory: initialPath == nil, transport: transport ) pane.sshSession?.connectionRoute = connectionRoute configureConnectionHistory(pane) @@ -995,10 +995,14 @@ final class WindowModel { 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) } @@ -1012,12 +1016,16 @@ final class WindowModel { return workspace } - /// Opens every target of one Snippet as visible SSH panes. SSH session and + /// Opens every target using its default SSH-authenticated terminal transport. /// Workspace titles keep their ordinary host-derived defaults; each pane /// submits the same command independently after login. @discardableResult func openSnippetWorkspace(hosts: [Host], command: String) -> Workspace? { - let panes = hosts.map { Pane.sshSession(host: $0, initialCommand: command) } + let panes = hosts.filter(\.sshEnabled).map { host in + Pane.sshSession( + host: host, initialCommand: command, + transport: host.terminalTransports.preferred(isTailscaleManaged: host.isTailscaleManaged)) + } guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil } let workspace = Workspace(pane: first) @@ -1074,7 +1082,7 @@ final class WindowModel { guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return } let kind: ConnectionHistoryEntry.Kind switch pane.kind { - case .sshSession: kind = .ssh + case .sshSession: kind = .terminal(pane.sshSession?.transport ?? .ssh) case .telnetSession: kind = .telnet case .sftpChannel: kind = .sftp case .serialSession, .empty: return diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 314e83b0..2814b684 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -1239,6 +1239,10 @@ private struct WelcomeWorkspaceTemplateRow: View { switch pane { case .ssh(let hostID): "\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · SSH" + case .mosh(let hostID): + "\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Mosh" + case .eternalTerminal(let hostID): + "\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Eternal Terminal" case .telnet(let hostID): "\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Telnet" case .sftp(let hostID): diff --git a/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift b/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift index 7081a6f3..7ff87df5 100644 --- a/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift +++ b/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift @@ -17,7 +17,11 @@ extension SplitNode { switch pane.content { case .sshSession(let session): guard hosts.host(session.hostID) != nil else { return nil } - return .pane(.ssh(hostID: session.hostID)) + switch session.transport { + case .mosh: return .pane(.mosh(hostID: session.hostID)) + case .eternalTerminal: return .pane(.eternalTerminal(hostID: session.hostID)) + case .ssh, .telnet: return .pane(.ssh(hostID: session.hostID)) + } case .telnetSession(let session): guard hosts.host(session.hostID) != nil else { return nil } return .pane(.telnet(hostID: session.hostID)) @@ -54,6 +58,14 @@ extension WorkspaceTemplateLayout { case .ssh(let hostID): guard let host = hosts.host(hostID), host.sshEnabled else { return (.leaf(Pane(.empty)), 1) } return (.leaf(.sshSession(host: host)), 0) + case .mosh(let hostID): + guard let host = hosts.host(hostID), host.isEnabled(.mosh) else { return (.leaf(Pane(.empty)), 1) } + return (.leaf(.sshSession(host: host, transport: .mosh)), 0) + case .eternalTerminal(let hostID): + guard let host = hosts.host(hostID), host.isEnabled(.eternalTerminal) else { + return (.leaf(Pane(.empty)), 1) + } + return (.leaf(.sshSession(host: host, transport: .eternalTerminal)), 0) case .telnet(let hostID): guard let host = hosts.host(hostID), host.telnetEnabled else { return (.leaf(Pane(.empty)), 1) } return (.leaf(.telnetSession(host: host)), 0) diff --git a/TFMTests/ConnectionHistoryTests.swift b/TFMTests/ConnectionHistoryTests.swift index 692ff7b3..b290f836 100644 --- a/TFMTests/ConnectionHistoryTests.swift +++ b/TFMTests/ConnectionHistoryTests.swift @@ -25,7 +25,8 @@ struct ConnectionHistoryTests { @Test func connectionProtocolSymbolsAreUniqueAndAvailable() { - #expect(Set(ConnectionHistoryEntry.Kind.allCases.map(\.symbol)).count == 3) + #expect( + Set(ConnectionHistoryEntry.Kind.allCases.map(\.symbol)).count == ConnectionHistoryEntry.Kind.allCases.count) #expect( ConnectionHistoryEntry.Kind.allCases.allSatisfy { NSImage(systemSymbolName: $0.symbol, accessibilityDescription: nil) != nil diff --git a/TFMTests/ConnectionJourneyTests.swift b/TFMTests/ConnectionJourneyTests.swift index 63de55dd..459e22e7 100644 --- a/TFMTests/ConnectionJourneyTests.swift +++ b/TFMTests/ConnectionJourneyTests.swift @@ -12,6 +12,32 @@ import struct TermindDomain.Host @Suite @MainActor struct ConnectionJourneyTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func resumableTerminalJourneyUsesTheSelectedTransport(_ transport: HostTerminalProtocol) { + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + let store = HostLibraryModel(hosts: [host]) + let session = SSHSessionModel(host: host) + session.transport = transport + defer { session.disconnect() } + + session.connect(using: store) + + let purpose = session.connectionJourney.purpose + #expect(purpose == .terminal(transport)) + #expect(purpose.protocolName == transport.displayName) + #expect(purpose.symbol(for: .header) == transport.symbol) + #expect(purpose.symbol(for: .routeDestination) == transport.symbol) + #expect(session.connectionJourney.plainTextLog.contains("Starting \(transport.displayName) session")) + #expect(session.connectionJourney.phase == .waiting) + + session.connection.presentReconnectJourney( + host: host, using: store, purpose: .terminal(transport), username: "user") + #expect(session.connectionJourney.purpose == purpose) + #expect(session.connectionJourney.plainTextLog.contains("Reconnecting \(transport.displayName) session")) + } + @Test func telnetUsesItsOwnSymbolThroughoutTheConnectionJourney() { #expect(ConnectionPurpose.telnetSession.symbol(for: .header) == "text.rectangle.fill") diff --git a/TFMTests/QuickOpenTests.swift b/TFMTests/QuickOpenTests.swift index 04e396df..8e25c0d3 100644 --- a/TFMTests/QuickOpenTests.swift +++ b/TFMTests/QuickOpenTests.swift @@ -14,6 +14,15 @@ import struct TermindDomain.Host @Suite @MainActor struct QuickOpenTests { + @Test + func resumableConnectActionsUseWithPrefix() { + let locale = Locale(identifier: "en") + #expect(AppLocalization.resolve(HostConnectAction.mosh.title, locale: locale) == "with Mosh") + #expect( + AppLocalization.resolve(HostConnectAction.eternalTerminal.title, locale: locale) + == "with Eternal Terminal") + } + @Test func nativeHostMenuCanDuplicateWithoutChangingTheOriginalHost() throws { let host = Host(name: "Production", host: "server.invalid", port: 2222) diff --git a/TFMTests/SnippetFeatureTests.swift b/TFMTests/SnippetFeatureTests.swift index 1e38278c..3d46bbc7 100644 --- a/TFMTests/SnippetFeatureTests.swift +++ b/TFMTests/SnippetFeatureTests.swift @@ -15,6 +15,31 @@ import struct TermindDomain.Host @Suite @MainActor struct SnippetFeatureTests { + @Test + func snippetPanesUseEachHostsDefaultTransport() throws { + var mosh = Host(name: "Mosh", host: "mosh.invalid") + mosh.terminalTransports.moshEnabled = true + mosh.terminalTransports.defaultTransport = .mosh + var eternal = Host(name: "ET", host: "et.invalid") + eternal.terminalTransports.eternalTerminalEnabled = true + eternal.terminalTransports.defaultTransport = .eternalTerminal + let ssh = Host(name: "SSH", host: "ssh.invalid") + let window = WindowModel() + let workspace = try #require(window.openSnippetWorkspace(hosts: [mosh, eternal, ssh], command: "uptime")) + defer { for pane in workspace.panes { pane.sshSession?.disconnect() } } + + #expect(workspace.panes.compactMap { $0.sshSession?.transport } == [.mosh, .eternalTerminal, .ssh]) + #expect(workspace.panes.map(\.hostID) == [mosh.id, eternal.id, ssh.id]) + for pane in workspace.panes { + let session = try #require(pane.sshSession) + var commands: [String] = [] + session.inputSink = { data in commands.append(String(decoding: data, as: UTF8.self)) } + session.submitPendingInitialCommand() + session.submitPendingInitialCommand() + #expect(commands == ["uptime\r"]) + } + } + @Test func snippetCardsReadCreationAndEditTimesFromTheVaultJournal() async throws { let container = try TermindPersistence.makeContainer(inMemory: true) diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 086eb023..21fb92f1 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 38462C442F34CFDE6FF4661D /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 38B690B07BB71C9BAA6B45B0 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; 3CEFDFDB25F7D9C9A795C601 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; + 439F7F2720CE538D0A77ABB0 /* MoshBootstrap in Frameworks */ = {isa = PBXBuildFile; productRef = 8FD77C0915F32A5BA679231F /* MoshBootstrap */; }; 5310B1DD3FA3FE77185B3F4A /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; 640E0A8248C5231A7424BE56 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; 6995A68F3132E9C67B0DA01E /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; @@ -37,6 +38,7 @@ 88E7F9CE7BC5631B487E2BC5 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 8BFD569E84CA09FDEFF87698 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; 8D2404FE022681C084DE0E23 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; + 8F54AB5B8FDF59C8A49EEC8D /* ETBootstrap in Frameworks */ = {isa = PBXBuildFile; productRef = 080FCAC89CDE2C755F682FC3 /* ETBootstrap */; }; 8F73CAE12C350F5FDB033EE0 /* GhosttyKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100016 /* GhosttyKit */; }; 8FCAF8FD0FF78E38C58ED887 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 9AF99D2C7AF5E879598A5972 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; @@ -85,10 +87,12 @@ DEVK00000000000000000004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DEVK00000000000000000002 /* DeviceKit */; }; E28A00000000000000000002 /* V28.store in Resources */ = {isa = PBXBuildFile; fileRef = E28A00000000000000000001 /* V28.store */; }; E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; + E9A6C576AF3870A1958F9C5A /* ETSession in Frameworks */ = {isa = PBXBuildFile; productRef = 6335BE33C2C5F92BE0568DF6 /* ETSession */; }; EF2F88E4809C845C3F1BBF63 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; F04700000000000000000001 /* TFMTests/SSHForwardingPumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */; }; F04700000000000000000002 /* TFMTests/SSHForwardingWireTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */; }; F04700000000000000000003 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = LOGG00000000000000000002 /* Logging */; }; + F16CFDDDB6CCEC48786E162F /* MoshCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5809494E158776907BA05655 /* MoshCore */; }; F1BA00000000000000000101 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; F1BA00000000000000000102 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000002 /* FirebaseCrashlytics */; }; F1BA00000000000000000201 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; @@ -648,6 +652,10 @@ ABCDEF020000000000000002 /* AgentKitScrubber in Frameworks */, 0C77A0D7A810A78DA1F6CD93 /* CSSH2 in Frameworks */, 7A115CA1E000000000000001 /* TailscaleKit in Frameworks */, + 8F54AB5B8FDF59C8A49EEC8D /* ETBootstrap in Frameworks */, + E9A6C576AF3870A1958F9C5A /* ETSession in Frameworks */, + 439F7F2720CE538D0A77ABB0 /* MoshBootstrap in Frameworks */, + F16CFDDDB6CCEC48786E162F /* MoshCore in Frameworks */, 0D54DF6508C7918D7D5B9D5A /* OpenSSL in Frameworks */, 0861B3AA103F736DEE93983B /* TermindInfrastructure.framework in Frameworks */, 88E7F9CE7BC5631B487E2BC5 /* TermindDomain.framework in Frameworks */, @@ -1136,6 +1144,10 @@ name = TermindRemoteInfrastructure; packageProductDependencies = ( 7A115CA1E000000000000002 /* TailscaleKit */, + 080FCAC89CDE2C755F682FC3 /* ETBootstrap */, + 6335BE33C2C5F92BE0568DF6 /* ETSession */, + 8FD77C0915F32A5BA679231F /* MoshBootstrap */, + 5809494E158776907BA05655 /* MoshCore */, LOGG00000000000000000002 /* Logging */, TRMD0000000000000010001C /* AgentKitScrubber */, TRMD00000000000000100011 /* CSSH2 */, @@ -1392,6 +1404,8 @@ mainGroup = TRMD00000000000000000030; minimizedProjectReferenceProxies = 1; packageReferences = ( + C1E0C225CFF81C5919AA14D0 /* XCRemoteSwiftPackageReference "swift-et" */, + 8CD75E28C1BB4D9F1890547E /* XCRemoteSwiftPackageReference "swift-mosh" */, 7A115CA1E000000000000003 /* XCRemoteSwiftPackageReference "libtailscale-spm" */, C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */, F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, @@ -2902,6 +2916,14 @@ minimumVersion = 1.0.3; }; }; + 8CD75E28C1BB4D9F1890547E /* XCRemoteSwiftPackageReference "swift-mosh" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/wiedymi/swift-mosh"; + requirement = { + kind = revision; + revision = 9677768702727ba9094d4936062a452b9c544480; + }; + }; BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/groue/GRDB.swift.git"; @@ -2918,6 +2940,14 @@ minimumVersion = 5.95.0; }; }; + C1E0C225CFF81C5919AA14D0 /* XCRemoteSwiftPackageReference "swift-et" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/wiedymi/swift-et"; + requirement = { + kind = revision; + revision = 6b7390b412fa46c59c54311bc688dd743b014a7b; + }; + }; DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/devicekit/DeviceKit.git"; @@ -3009,11 +3039,31 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 080FCAC89CDE2C755F682FC3 /* ETBootstrap */ = { + isa = XCSwiftPackageProductDependency; + package = C1E0C225CFF81C5919AA14D0 /* XCRemoteSwiftPackageReference "swift-et" */; + productName = ETBootstrap; + }; + 5809494E158776907BA05655 /* MoshCore */ = { + isa = XCSwiftPackageProductDependency; + package = 8CD75E28C1BB4D9F1890547E /* XCRemoteSwiftPackageReference "swift-mosh" */; + productName = MoshCore; + }; + 6335BE33C2C5F92BE0568DF6 /* ETSession */ = { + isa = XCSwiftPackageProductDependency; + package = C1E0C225CFF81C5919AA14D0 /* XCRemoteSwiftPackageReference "swift-et" */; + productName = ETSession; + }; 7A115CA1E000000000000002 /* TailscaleKit */ = { isa = XCSwiftPackageProductDependency; package = 7A115CA1E000000000000003 /* XCRemoteSwiftPackageReference "libtailscale-spm" */; productName = TailscaleKit; }; + 8FD77C0915F32A5BA679231F /* MoshBootstrap */ = { + isa = XCSwiftPackageProductDependency; + package = 8CD75E28C1BB4D9F1890547E /* XCRemoteSwiftPackageReference "swift-mosh" */; + productName = MoshBootstrap; + }; BAAD00000000000000000002 /* GRDB */ = { isa = XCSwiftPackageProductDependency; package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 565be06c..564b7799 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3e81c11f7e95db321267fafb4222531159b6a2f6a2e2d18a87b3fc6ce2cf1caf", + "originHash" : "c47e3025181cb7d616f69e79afb3706a03898b1ebc4dc34263f4b85a14b19dec", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -289,6 +289,14 @@ "version" : "1.7.1" } }, + { + "identity" : "swift-et", + "kind" : "remoteSourceControl", + "location" : "https://github.com/wiedymi/swift-et", + "state" : { + "revision" : "6b7390b412fa46c59c54311bc688dd743b014a7b" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -298,6 +306,23 @@ "version" : "1.16.1" } }, + { + "identity" : "swift-mosh", + "kind" : "remoteSourceControl", + "location" : "https://github.com/wiedymi/swift-mosh", + "state" : { + "revision" : "9677768702727ba9094d4936062a452b9c544480" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "3d537cb81aef272a03cb3be815439e9bf0eb3bd3", + "version" : "1.39.0" + } + }, { "identity" : "swiftmath", "kind" : "remoteSourceControl", diff --git a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift index 5a1d8227..f8b8af49 100644 --- a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift +++ b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift @@ -104,7 +104,7 @@ nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { let rawHost = items.first(where: { $0.name == "host" })?.value, let host = UUID(uuidString: rawHost), let kind = items.first(where: { $0.name == "kind" })?.value, - ["ssh", "sftp", "telnet"].contains(kind) + ["ssh", "mosh", "eternalTerminal", "sftp", "telnet"].contains(kind) else { return nil } return Recovery(sourceWorkspaceID: id, hostID: host, kind: kind) }