feat(terminal): add Mosh and Eternal Terminal transports
Add SSH-authenticated transports with independent settings, protocol-aware menus and snippets, and tmux session persistence. Restore eligible iOS workspaces from encrypted transport checkpoints without adding a macOS recovery entry. Include schema migrations and regression coverage.
This commit is contained in:
1 parent
5e19dcc219
commit
9738605dd3
79 files changed
+4096
-158
No files matched your search
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<SSHHostKeyIdentity> { [] }
|
||||
}
|
||||
|
||||
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<sockaddr_in>.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<sockaddr_in>.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<Int32>.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Value>(_ 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<HistoryEnvelope>(
|
||||
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<HistoryEnvelope> = {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = 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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
private var observer: Task<Void, Never>?
|
||||
private var maintenance: Task<Void, Never>?
|
||||
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<Void, Never>] = []
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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<sockaddr_in>.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<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
var length = socklen_t(MemoryLayout<sockaddr_in>.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<Int32>.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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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<Void, Never>?
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" */;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user