diff --git a/FrameworkTests/SSHAgentForwardingPersistenceTests.swift b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift index e5ba23d5..81dde262 100644 --- a/FrameworkTests/SSHAgentForwardingPersistenceTests.swift +++ b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift @@ -37,10 +37,12 @@ struct SSHAgentForwardingPersistenceTests { } @Test func hostVersionTwoRoundTripsAndCannotBeWrittenAsVersionOne() throws { - let host = TermindDomain.Host(name: "Forwarded", host: "localhost", agentForwardingEnabled: true) + let host = TermindDomain.Host( + name: "Forwarded", host: "localhost", agentForwardingEnabled: true, + sessionPersistence: .init(enabled: false)) let payload = VaultRecordPayload.host(host) var envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) - #expect(envelope.formatVersion == 2) + envelope.formatVersion = 2 #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) envelope.formatVersion = 1 #expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) } diff --git a/FrameworkTests/SessionPersistenceTests.swift b/FrameworkTests/SessionPersistenceTests.swift new file mode 100644 index 00000000..30792cea --- /dev/null +++ b/FrameworkTests/SessionPersistenceTests.swift @@ -0,0 +1,592 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import TermindRemoteInfrastructure +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct SessionPersistenceTests { + @Test func newHostsDisablePersistenceAndAskEveryTimeWhenEnabled() { + let host = Host(name: "New", host: "example.invalid") + let tailscale = TailscaleHostPreferences() + for preferences in [host.sessionPersistence, tailscale.sessionPersistence] { + #expect(!preferences.enabled) + #expect(preferences.backend == .tmux) + #expect(preferences.onConnect == .ask) + } + } + + @Test(arguments: [false, true]) + func preferencesRoundTripWithoutResettingSelections(enabled: Bool) throws { + var host = Host(name: "Saved", host: "example.invalid") + host.sessionPersistence = .init(enabled: enabled, backend: .init(rawValue: "future"), onConnect: .createManaged) + #expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host) + #expect(PersistentHost(host).value.sessionPersistence == host.sessionPersistence) + let envelope = VaultEnvelope( + key: .init(kind: .host, id: host.id), version: .baseline(writerID: UUID()), payload: .host(host)) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)).payload == .host(host)) + var legacy = envelope + legacy.formatVersion = 2 + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(legacy) } + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(host)) as? [String: Any]) + json["sessionPersistence"] = nil + let old = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.sessionPersistence == .init(enabled: false)) + } + + @Test func legacyTailscalePreferencesStayDisabled() throws { + let preferences = TailscaleHostPreferences() + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(preferences)) as? [String: Any]) + json["sessionPersistence"] = nil + let old = try JSONDecoder().decode( + TailscaleHostPreferences.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.sessionPersistence == .init(enabled: false)) + } + + @Test func legacyHostWirePreservesDisabledBehaviorAndRejectsEnabledPreferences() throws { + var host = Host(name: "Legacy", host: "example.invalid") + host.sessionPersistence.enabled = true + for version in [1, 2] { + let enabled = VaultRecordPayload.host(host) + let enabledEnvelope = VaultEnvelope( + key: enabled.key, version: .baseline(writerID: UUID()), payload: enabled, formatVersion: version) + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(enabledEnvelope) } + host.sessionPersistence.enabled = false + let disabled = VaultRecordPayload.host(host) + let disabledEnvelope = VaultEnvelope( + key: disabled.key, version: .baseline(writerID: UUID()), payload: disabled, formatVersion: version) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(disabledEnvelope)).payload == disabled) + host.sessionPersistence.enabled = true + } + } + + @Test(arguments: [false, true]) + func tailscaleWirePreservesSelectionsAndRejectsLossyOlderWrites(enabled: Bool) throws { + let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net") + var group = HostGroup(name: "Tailnet") + group.tailscale = TailscaleGroupConfiguration(network: network) + let id = UUID() + var preferences = TailscaleHostPreferences() + preferences.sessionPersistence = .init( + enabled: enabled, backend: .init(rawValue: "future"), onConnect: .plainShell) + group.tailscale?.hostPreferences[id] = preferences + let payload = VaultRecordPayload.hostGroup(group) + var envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)).payload == payload) + envelope.formatVersion = 3 + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) } + preferences.sessionPersistence = .init(enabled: false) + group.tailscale?.hostPreferences[id] = preferences + let legacy = VaultRecordPayload.hostGroup(group) + let legacyEnvelope = VaultEnvelope( + key: legacy.key, version: .baseline(writerID: UUID()), payload: legacy, formatVersion: 3) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(legacyEnvelope)).payload == legacy) + } + + @Test func historicalDiskStoreMigratesAndReopens() throws { + 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("Termind.store") + let host = Host( + name: "Historical", host: "example.invalid", startupSnippetID: UUID(), + sessionPersistence: .init(enabled: false)) + try writeHistorical(host, url: url) + try checkMigrated(host, url: url) + try checkMigrated(host, url: url) + } + + private func writeHistorical(_ host: Host, url: URL) throws { + let schema = Schema(versionedSchema: TermindSchemaV52.self) + let container = try ModelContainer( + for: schema, configurations: [.init("Termind", schema: schema, url: url, cloudKitDatabase: .none)]) + container.mainContext.insert(TermindSchemaV44HostModels.PersistentHost(host)) + try container.mainContext.save() + } + + private func checkMigrated(_ host: Host, url: URL) throws { + let container = try TermindPersistence.makeContainer(url: url) + let stored = try #require(container.mainContext.fetch(FetchDescriptor()).first) + #expect(stored.value == host) + #expect(!stored.value.sessionPersistence.enabled) + } + + @Test func plainPolicyNeverProbesAndPersistentExplicitCommandsCreateOnce() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + let host = UUID() + let plain = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .plainShell) + #expect(plain == nil) + #expect(await backend.probeCount == 0) + let plainCommand = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "echo normal", requiresNewSession: true, shellIntegration: false, onConnect: .plainShell) + #expect(plainCommand == nil) + #expect(await backend.probeCount == 0) + let created = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "echo once", requiresNewSession: true, shellIntegration: false, onConnect: .ask) + #expect(created?.attachment.isManaged == true) + let restored = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "must-not-run", requiresNewSession: false, shellIntegration: false, + onConnect: .createManaged) + #expect(restored?.created == false) + #expect(await backend.commands == ["echo once"]) + } + + @Test func lostReferenceAsksInsteadOfApplyingCreatePolicy() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + let host = UUID() + _ = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged) + await backend.removeSessions() + let task = Task { + try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged) + } + try await wait { coordinator.prompt != nil } + #expect(coordinator.prompt?.failure == .sessionMissing) + coordinator.cancel() + do { + _ = try await task.value + Issue.record("Cancelled selection must not launch") + } catch is CancellationError {} catch { throw error } + #expect(await backend.commands.count == 1) + #expect(coordinator.requiresSelection) + } + + @Test func manualAttachNeverTakesManagedOwnershipAndStaleChoicesAreIgnored() async throws { + let backend = Backend() + _ = try await backend.create(token: UUID(), startupCommand: nil, shellIntegration: false) + let coordinator = RemoteSessionCoordinator() + let task = Task { + try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false) + } + try await wait { coordinator.prompt != nil } + let prompt = try #require(coordinator.prompt) + coordinator.choose(.plainShell, for: UUID()) + #expect(coordinator.prompt?.id == prompt.id) + coordinator.choose(.attach(try #require(prompt.sessions.first)), for: prompt.id) + let attached = try await task.value + #expect(attached?.attachment.isManaged == false) + } + + @Test func unknownBackendPromptsWithoutRunningCommands() async throws { + let coordinator = RemoteSessionCoordinator() + let task = Task { + try await coordinator.prepare( + preferences: .init(enabled: true, backend: .init(rawValue: "other")), + registry: .init([:]), + execute: { _ in + Issue.record("Must not execute") + throw CancellationError() + }, + hostID: UUID(), endpoint: "host:22", username: "u", startupCommand: nil, requiresNewSession: false, + shellIntegration: false) + } + try await wait { coordinator.prompt != nil } + let prompt = try #require(coordinator.prompt) + #expect(prompt.failure == .unsupported) + coordinator.choose(.plainShell, for: prompt.id) + #expect(try await task.value == nil) + } + + @Test func unsupportedReferenceVersionsDoNotProbeOrReplaceTheSession() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + _ = try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged) + coordinator.attachment?.version = 2 + let probes = await backend.probeCount + let task = Task { + try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged) + } + try await wait { coordinator.prompt != nil } + #expect(coordinator.prompt?.failure == .unsupported) + #expect(await backend.probeCount == probes) + #expect(await backend.commands.count == 1) + coordinator.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + } + + @Test func endedSessionsRequireAChoiceInsteadOfAutomaticReplacement() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + coordinator.markEnded() + let task = Task { + try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged) + } + try await wait { coordinator.prompt != nil } + #expect(await backend.commands.isEmpty) + #expect(coordinator.attachment == nil) + #expect(!coordinator.detached) + coordinator.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + } + + @Test(arguments: [Int32(0), Int32(1)], [Optional(true), Optional(false), nil]) + func exitClosesOnlyConfirmedSuccessfulEnds(exitCode: Int32, sessionExists: Bool?) { + let coordinator = RemoteSessionCoordinator() + let saved = reference() + coordinator.attachment = saved + var cleared = false + coordinator.didChangeAttachment = { cleared = $0 == nil } + + let shouldClose = coordinator.finishExit(exitCode: exitCode, sessionExists: sessionExists) + + #expect(shouldClose == (exitCode == 0 && sessionExists == false)) + if sessionExists == false { + #expect(coordinator.attachment == nil) + #expect(cleared) + #expect(coordinator.requiresSelection) + #expect(!coordinator.detached) + } else { + #expect(coordinator.attachment == saved) + #expect(!cleared) + #expect(coordinator.detached) + } + } + + @Test func selectionPausesJourneyAndCancelResumesWithoutLaunching() async throws { + let coordinator = RemoteSessionCoordinator() + let backend = Backend() + let host = Host(name: "Server", host: "server.invalid") + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: .sshSession, username: "u") + coordinator.didChangePrompt = { pending in + if pending { journey.waitForSessionSelection() } else { journey.resume() } + } + let task = Task { + try await coordinator.prepare( + service: backend, hostID: host.id, endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .ask) + } + try await wait { coordinator.prompt != nil } + #expect(journey.phase == .waiting) + #expect(journey.activeEndpointIndex == journey.endpoints.count - 1) + #expect(journey.checkpoint == nil) + #expect(journey.localRequirement == nil) + coordinator.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + #expect(journey.phase == .connecting) + #expect(coordinator.prompt == nil) + #expect(await backend.commands.isEmpty) + } + + @Test func cancellingSelectionClosesBeforePreparationReportsCancellation() async throws { + let coordinator = RemoteSessionCoordinator() + var closed = false + let task = Task { + do { + try await coordinator.confirmPlainConnection() + Issue.record("Cancelled selection must not launch a connection") + } catch is CancellationError { + #expect(closed) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + try await wait { coordinator.prompt != nil } + let id = try #require(coordinator.prompt?.id) + coordinator.cancelSelection(for: id) { closed = true } + #expect(closed) + #expect(coordinator.prompt == nil) + await task.value + } + + @Test func staleSelectionCancellationDoesNotCloseCurrentConnection() async throws { + let coordinator = RemoteSessionCoordinator() + let task = Task { try await coordinator.confirmPlainConnection() } + try await wait { coordinator.prompt != nil } + let id = try #require(coordinator.prompt?.id) + coordinator.cancelSelection(for: UUID()) { Issue.record("Stale selection closed the connection") } + #expect(coordinator.prompt?.id == id) + coordinator.choose(.plainShell, for: id) + try await task.value + } + + @Test func creationRequiresMatchingRemoteOwnershipConfirmation() async throws { + let name = Array("session\n".utf8).map { String(format: "%02x", $0) }.joined() + let service = RemoteTmuxService { _ in + .init(stdout: "TERMIND_TMUX_1\nS $1 123 456 1 0 \(name) -\n", stderr: "", exitCode: 0) + } + await #expect(throws: RemoteSessionFailure.ownershipMismatch) { + try await service.create(token: UUID(), startupCommand: nil, shellIntegration: false) + } + } + + @Test func probeSeparatesMissingUnsupportedTimeoutAndMalformedResponses() async throws { + for (result, failure) in [ + (RemoteSessionCommandResult(stdout: "", stderr: "", exitCode: 77), RemoteSessionFailure.missing), + (.init(stdout: "tmux 3.2a\n", stderr: "", exitCode: 0), .unsupported), + (.init(stdout: "unrecognized\n", stderr: "", exitCode: 0), .invalidResponse), + (.init(stdout: "tmux 3.3a\n", stderr: "", exitCode: 0, truncated: true), .invalidResponse), + ] { + let backend = RemoteTmuxService { _ in result } + await #expect(throws: failure) { try await backend.probe() } + } + let timeout = RemoteTmuxService { _ in throw RemoteSessionFailure.timedOut } + await #expect(throws: RemoteSessionFailure.timedOut) { try await timeout.probe() } + let empty = RemoteTmuxService { _ in .init(stdout: "TERMIND_TMUX_1\n", stderr: "", exitCode: 0) } + #expect(try await empty.sessions().isEmpty) + } + + @Test func closeFailureRetainsRemainingWorkAndQuitDoesNotTerminate() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + let launch = try #require( + try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged)) + let close = RemoteSessionCloseCoordinator() + #expect( + await close.close(attachments: [launch.attachment], intent: .applicationTermination) { _ in + Issue.record("Quit must preserve") + }) + var attempts = 0 + let task = Task { + await close.close(attachments: [launch.attachment]) { _ in + attempts += 1 + throw RemoteSessionFailure.ownershipMismatch + } + } + try await wait { close.prompt?.failure != nil } + #expect(attempts == 1) + close.choose(.cancel, for: try #require(close.prompt?.id)) + #expect(await task.value == false) + } + + @Test func explicitCloseEndsManagedSessionsWithoutAConfirmation() async { + let close = RemoteSessionCloseCoordinator() + let managed = reference() + let external = RemoteSessionReference( + backend: managed.backend, session: managed.session, hostID: managed.hostID, + endpoint: managed.endpoint, username: managed.username, ownershipToken: nil) + var ended: [RemoteSessionReference] = [] + + let allowed = await close.close(attachments: [managed, external]) { session in + #expect(close.prompt == nil) + ended.append(session) + } + + #expect(allowed) + #expect(ended == [managed]) + #expect(close.prompt == nil) + #expect(!close.isClosing) + } + + @Test(arguments: [ + TerminalTeardownIntent.applicationTermination, .suspension, .remoteExit, .detach, + ]) + func passiveTeardownPreservesManagedSessions(intent: TerminalTeardownIntent) async { + let close = RemoteSessionCloseCoordinator() + #expect( + await close.close(attachments: [reference()], intent: intent) { _ in + Issue.record("Passive teardown must not terminate a remote session") + }) + #expect(close.prompt == nil) + } + + @Test func tmuxParsingKeepsNamesAsDataAndRejectsUnsupportedVersions() throws { + let name = "odd ' $() ; \\ name" + let hex = (name + "\n").utf8.map { String(format: "%02x", $0) }.joined() + let result = try RemoteTmuxCommands.parseSessions("TERMIND_TMUX_1\nS $7 123 456 2 3 \(hex) -\n") + #expect(result.first?.name == name) + #expect(result.first?.windows == 2) + #expect(try RemoteTmuxCommands.quote("a'b") == "'a'\\''b'") + #expect(throws: RemoteSessionFailure.unsupported) { try RemoteTmuxCommands.validateVersion("tmux 3.2a\n") } + try RemoteTmuxCommands.validateVersion("tmux 3.3a\n") + #expect(throws: (any Error).self) { try RemoteTmuxCommands.parseSessions("TERMIND_TMUX_1\nS $bad\n") } + } + + @Test func uncertainCreatesReuseTheirTokenOnlyForTheSameLaunchIntent() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + let host = UUID() + await backend.failNextCreate() + let first = Task { + try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "first", requiresNewSession: true, shellIntegration: false) + } + try await wait { coordinator.prompt != nil } + coordinator.choose(.retry, for: try #require(coordinator.prompt?.id)) + _ = try await first.value + let retriedTokens = await backend.tokens + #expect(retriedTokens.count == 2) + #expect(retriedTokens.first == retriedTokens.last) + coordinator.clearAttachment() + + await backend.failNextCreate() + let cancelled = Task { + try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "second", requiresNewSession: true, shellIntegration: false) + } + try await wait { coordinator.prompt != nil } + coordinator.cancel() + await #expect(throws: CancellationError.self) { try await cancelled.value } + _ = try await coordinator.prepare( + service: backend, hostID: host, endpoint: "host:22", username: "u", + startupCommand: "different", requiresNewSession: true, shellIntegration: false) + let tokens = await backend.tokens + #expect(tokens.count == 4) + #expect(tokens[2] != tokens[3]) + } + + @Test func staleCreationCannotPublishOwnershipOverANewerConnection() async throws { + let oldBackend = Backend() + await oldBackend.pauseNextCreate() + let coordinator = RemoteSessionCoordinator() + let old = Task { + try await coordinator.prepare( + service: oldBackend, hostID: UUID(), endpoint: "old:22", username: "u", + startupCommand: nil, requiresNewSession: true, shellIntegration: false) + } + for _ in 0..<200 { + if await oldBackend.isCreatePaused { break } + try await Task.sleep(for: .milliseconds(5)) + } + #expect(await oldBackend.isCreatePaused) + let current = try await coordinator.prepare( + service: Backend(), hostID: UUID(), endpoint: "new:22", username: "u", + startupCommand: nil, requiresNewSession: true, shellIntegration: true) + await oldBackend.resumeCreate() + await #expect(throws: CancellationError.self) { try await old.value } + #expect(coordinator.attachment == current?.attachment) + } + + @Test func unavailableShellCapabilityDoesNotReceiveIntegrationRequests() async throws { + let backend = Backend() + let coordinator = RemoteSessionCoordinator() + _ = try await coordinator.prepare( + service: backend, hostID: UUID(), endpoint: "host:22", username: "u", + startupCommand: nil, requiresNewSession: true, shellIntegration: true) + #expect(await backend.integrationRequests == [false]) + } + + @Test func duplicateCloseReferencesAreEndedOnceAndRetryKeepsPartialProgress() async throws { + let first = reference() + var duplicate = first + duplicate.session = .init( + id: first.session.id, name: "Renamed", identity: first.session.identity, windows: 2, clients: 3) + let second = reference() + let close = RemoteSessionCloseCoordinator() + var terminated: [RemoteSessionReference] = [] + var fail = true + let task = Task { + await close.close(attachments: [first, duplicate, second]) { reference in + if reference == second, fail { + fail = false + throw RemoteSessionFailure.timedOut + } + terminated.append(reference) + } + } + try await wait { close.prompt?.failure != nil } + #expect(terminated == [first]) + #expect(close.prompt?.names == [second.session.name]) + close.choose(.endSessions, for: try #require(close.prompt?.id)) + #expect(await task.value) + #expect(terminated == [first, second]) + } + + @Test func cancellationDuringBatchCloseDoesNotEndAdditionalSessions() async throws { + let close = RemoteSessionCloseCoordinator() + var ended = 0 + let task = Task { + await close.close(attachments: [reference(), reference()]) { _ in + ended += 1 + close.cancel() + } + } + #expect(await task.value == false) + #expect(ended == 1) + } + + private func reference() -> RemoteSessionReference { + .init( + backend: .init(rawValue: "test"), session: .init(id: UUID().uuidString, name: "Session", identity: Data()), + hostID: UUID(), endpoint: "host:22", username: "u", ownershipToken: UUID()) + } + + private func wait(_ condition: () -> Bool) async throws { + for _ in 0..<200 { + if condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition()) + } + + private actor Backend: RemoteSessionBackend { + nonisolated let id = SessionPersistenceBackendID(rawValue: "test-backend") + nonisolated let capabilities = RemoteSessionCapabilities() + var probeCount = 0 + var commands: [String?] = [] + var tokens: [UUID] = [] + var integrationRequests: [Bool] = [] + private var shouldFailCreate = false + private var shouldPauseCreate = false + private var createContinuation: CheckedContinuation? + var isCreatePaused: Bool { createContinuation != nil } + var current: [RemoteSessionDescriptor] = [] + func failNextCreate() { shouldFailCreate = true } + func pauseNextCreate() { shouldPauseCreate = true } + func resumeCreate() { + createContinuation?.resume() + createContinuation = nil + } + func sessions() async throws -> [RemoteSessionDescriptor] { + probeCount += 1 + return current + } + func removeSessions() { current = [] } + nonisolated func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool { + reference.session.identity == session.identity + } + func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] + ) async throws + -> RemoteSessionDescriptor + { + tokens.append(token) + integrationRequests.append(shellIntegration) + if shouldFailCreate { + shouldFailCreate = false + throw RemoteSessionFailure.timedOut + } + if shouldPauseCreate { + shouldPauseCreate = false + await withCheckedContinuation { createContinuation = $0 } + } + commands.append(startupCommand) + let session = RemoteSessionDescriptor( + id: UUID().uuidString, name: "A test session", identity: Data(token.uuidString.utf8)) + current.append(session) + return session + } + nonisolated func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch { + .init(command: "attach-test", attachment: attachment, created: created) + } + func terminate(_ attachment: RemoteSessionReference) async throws { + current.removeAll { $0.id == attachment.session.id } + } + } +} diff --git a/FrameworkTests/TerminalContextCoordinatorTests.swift b/FrameworkTests/TerminalContextCoordinatorTests.swift new file mode 100644 index 00000000..eb4b6eb6 --- /dev/null +++ b/FrameworkTests/TerminalContextCoordinatorTests.swift @@ -0,0 +1,163 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct TerminalContextCoordinatorTests { + private func context(_ pane: String = "a", instance: String = "one") -> TerminalShellContext { + .init( + identity: .init(backend: "test", server: "server", session: "session", pane: pane, shellInstance: instance), + shell: "bash", directory: "/\(pane)", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + } + + @Test func activeContextIsNotInferredFromEventSource() { + let model = TerminalContextCoordinator() + let a = context() + let b = context("b") + let update = TerminalRuntimeUpdate( + status: .multiplexer, active: a.identity, contexts: [a, b], + events: [.init(context: b.identity, sequence: 1, payload: .directory("/wrong"))]) + model.receive(update, generation: model.generation) + #expect(model.directory == "/a") + #expect(model.active?.identity == a.identity) + } + + @Test func ticketsExpireOnInputSwitchDisconnectAndCapabilityLoss() throws { + let model = TerminalContextCoordinator() + let a = context() + model.receive(.init(status: .ordinary, active: a.identity, contexts: [a]), generation: model.generation) + let ticket = try #require(model.ticket(requiring: .readDraft)) + model.noteInput() + #expect(!model.isCurrent(ticket, requiring: .readDraft)) + let next = try #require(model.ticket(requiring: .readDraft)) + model.receive(.init(status: .unavailable), generation: model.generation) + #expect(model.directory == nil) + #expect(!model.isCurrent(next, requiring: .readDraft)) + let old = model.generation + model.reset() + model.receive(.init(status: .ordinary, active: a.identity, contexts: [a]), generation: old) + #expect(model.active == nil) + } + + @Test func journalReplayDoesNotReannounceOldCommandsAndGapsCannotCompleteThem() { + let model = TerminalContextCoordinator() + var a = context() + a.sequence = 9 + let past = TerminalContextEvent(context: a.identity, sequence: 8, payload: .commandStarted("old")) + #expect( + model.receive( + .init(status: .multiplexer, active: a.identity, contexts: [a], events: [past]), + generation: model.generation + ).isEmpty) + let start = TerminalContextEvent(context: a.identity, sequence: 10, payload: .commandStarted("new")) + let finish = TerminalContextEvent( + context: a.identity, sequence: 12, payload: .commandFinished(0, duration: 1)) + a.sequence = 12 + let accepted = model.receive( + .init(status: .multiplexer, active: a.identity, contexts: [a], events: [start, finish]), + generation: model.generation) + #expect(accepted == [start]) + #expect( + model.receive( + .init(status: .multiplexer, active: a.identity, contexts: [a], events: [start, finish]), + generation: model.generation + ).isEmpty) + } + + @Test func independentPanesCompleteWithoutOverwritingEachOther() { + let model = TerminalContextCoordinator() + let monitor = TerminalCommandMonitor() + monitor.beginConnection() + let a = context() + let b = context("b") + model.receive(.init(status: .multiplexer, active: a.identity, contexts: [a, b]), generation: model.generation) + var nextA = a + var nextB = b + nextA.sequence = 3 + nextB.sequence = 3 + let events: [TerminalContextEvent] = [ + .init(context: a.identity, sequence: 2, payload: .commandStarted("first")), + .init(context: b.identity, sequence: 2, payload: .commandStarted("second")), + .init(context: a.identity, sequence: 3, payload: .commandFinished(0, duration: 10)), + .init(context: b.identity, sequence: 3, payload: .commandFinished(1, duration: 20)), + ] + monitor.receiveRuntimeEvents( + model.receive( + .init(status: .multiplexer, active: a.identity, contexts: [nextA, nextB], events: events), + generation: model.generation)) + #expect(monitor.unreadCompletionCount == 2) + monitor.receiveOSC133(exitCode: 0, durationNanos: 20_000_000_000) + #expect(monitor.unreadCompletionCount == 2) + } + + @Test func anUnconfirmedForegroundDoesNotForgetTheOuterShellCommand() { + let model = TerminalContextCoordinator() + var shell = context() + shell = .init( + identity: .init(backend: "shell", server: "c", session: "c", pane: "shell", shellInstance: "s"), + shell: shell.shell, directory: shell.directory, home: shell.home, promptReady: false, + sequence: 1, capabilities: [.commandLifecycle]) + model.receive( + .init( + status: .unknown, contexts: [shell], + events: [.init(context: shell.identity, sequence: 1, payload: .commandStarted("sleep 2"))]), + generation: model.generation) + model.receive(.init(status: .unknown), generation: model.generation) + let event = TerminalContextEvent( + context: shell.identity, sequence: 2, payload: .commandFinished(0, duration: 2)) + shell.sequence = 2 + #expect( + model.receive( + .init(status: .unknown, contexts: [shell], events: [event]), generation: model.generation) == [event]) + } + + @Test func aRuntimeAdapterDoesNotNeedATmuxDataModel() async throws { + struct Adapter: TerminalRuntimeAdapter { + let update: TerminalRuntimeUpdate + func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { update } + } + let model = TerminalContextCoordinator() + let context = context("different-backend") + try await model.refresh( + using: Adapter(update: .init(status: .multiplexer, active: context.identity, contexts: [context])), + terminal: "opaque-terminal-binding") + #expect(model.directory == "/different-backend") + #expect(model.ticket(requiring: .replaceDraft) != nil) + var old = context + old.sequence = 0 + old.directory = "/stale" + model.receive(.init(status: .multiplexer, active: old.identity, contexts: [old]), generation: model.generation) + #expect(model.directory == "/different-backend") + } + + @Test func aCommandBetweenPollsInvalidatesDraftsEvenWhenThePromptLooksUnchanged() throws { + let model = TerminalContextCoordinator() + var shell = context() + model.receive( + .init(status: .multiplexer, active: shell.identity, contexts: [shell]), generation: model.generation) + let draft = try #require(model.ticket(requiring: .replaceDraft)) + shell.sequence = 2 + model.receive( + .init( + status: .multiplexer, active: shell.identity, contexts: [shell], + events: [ + .init(context: shell.identity, sequence: 2, payload: .draft(request: "request", value: "text")) + ]), + generation: model.generation) + #expect(model.isCurrent(draft, requiring: .replaceDraft)) + shell.sequence = 5 + model.receive( + .init( + status: .multiplexer, active: shell.identity, contexts: [shell], + events: [ + .init(context: shell.identity, sequence: 3, payload: .commandStarted("true")), + .init(context: shell.identity, sequence: 4, payload: .commandFinished(0, duration: 0)), + .init(context: shell.identity, sequence: 5, payload: .promptReady), + ]), + generation: model.generation) + #expect(!model.isCurrent(draft, requiring: .replaceDraft)) + #expect(model.directory == shell.directory) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index ab268de1..695c721b 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1850,6 +1850,53 @@ } } }, + "%lld connected clients" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld verbundene Clients" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld connected clients" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients connectés" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続中のクライアント%lld個" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결된 클라이언트 %lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld个已连接客户端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個已連線用戶端" + } + } + } + }, "%lld connected · %lld workspaces" : { "extractionState" : "manual", "localizations" : { @@ -3802,6 +3849,100 @@ } } }, + "%lld windows" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Fenster" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld windows" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld fenêtres" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウインドウ%lld個" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "창 %lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld个窗口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個視窗" + } + } + } + }, + "%lld windows, %lld connected clients" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Fenster, %lld verbundene Clients" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld windows, %lld connected clients" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld fenêtres, %lld clients connectés" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウインドウ%lld個、接続中のクライアント%lld個" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "창 %lld개, 연결된 클라이언트 %lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld个窗口,%lld个已连接客户端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld個視窗,%lld個已連線用戶端" + } + } + } + }, "%lld workspaces" : { "extractionState" : "manual", "localizations" : { @@ -9539,6 +9680,53 @@ } } }, + "Ask Every Time" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jedes Mal fragen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask Every Time" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours demander" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎回確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항상 묻기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每次询问" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "每次詢問" + } + } + } + }, "Ask a question or describe what you want to do." : { "extractionState" : "manual", "localizations" : { @@ -10844,6 +11032,53 @@ } } }, + "Backend" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backend" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backend" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バックエンド" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "백엔드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "后端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "後端" + } + } + } + }, "Basics" : { "extractionState" : "manual", "localizations" : { @@ -12984,6 +13219,53 @@ } } }, + "Choose a Remote Session" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remotesitzung auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a Remote Session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une session distante" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションを選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션 선택" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择远端会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇遠端工作階段" + } + } + } + }, "Choose a SKILL.md file, or a folder that contains one." : { "comment" : "Title of a dialog box that appears when the user wants to import a skill.", "extractionState" : "manual", @@ -17375,6 +17657,53 @@ } } }, + "Connection was cancelled. Retry to connect." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Verbindung wurde abgebrochen. Versuchen Sie erneut, eine Verbindung herzustellen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection was cancelled. Retry to connect." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La connexion a été annulée. Réessayez pour vous connecter." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続がキャンセルされました。再試行すると接続できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결이 취소되었습니다. 연결하려면 다시 시도하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已取消连接。重试即可连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已取消連線。重試即可連線。" + } + } + } + }, "Connections" : { "extractionState" : "manual", "localizations" : { @@ -22801,6 +23130,53 @@ } } }, + "Detached from the remote session. Reconnect to attach again." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von der Remotesitzung getrennt. Zum erneuten Anhängen wieder verbinden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detached from the remote session. Reconnect to attach again." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session distante détachée. Reconnectez-vous pour vous y rattacher." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションからデタッチしました。再接続すると再びアタッチします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션에서 분리되었습니다. 다시 연결하면 세션에 재접속합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已脱离远端会话。重新连接以再次附着。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已脫離遠端工作階段。重新連線以再次附加。" + } + } + } + }, "Details" : { "comment" : "A segmented control that lets the user select different views within a server's detailed metrics.", "extractionState" : "manual", @@ -23768,6 +24144,53 @@ } } }, + "Disconnect Only" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur trennen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect Only" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déconnecter uniquement" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続のみ解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결만 해제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅断开连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅中斷連線" + } + } + } + }, "Disconnected" : { "extractionState" : "manual", "localizations" : { @@ -26538,6 +26961,147 @@ } } }, + "End Managed Remote Sessions?" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwaltete Remotesitzungen beenden?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End Managed Remote Sessions?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer les sessions distantes gérées ?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理対象のリモートセッションを終了しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관리 중인 원격 세션을 종료할까요?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结束托管远端会话?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束受管理的遠端工作階段?" + } + } + } + }, + "End Sessions and Close" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzungen beenden und schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End Sessions and Close" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer les sessions et fermer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションを終了して閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 종료 후 닫기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结束会话并关闭" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束工作階段並關閉" + } + } + } + }, + "Ending these sessions stops their remote processes for all clients. Cancel keeps them running." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Beenden stoppt die Remoteprozesse für alle Clients. Abbrechen lässt sie weiterlaufen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ending these sessions stops their remote processes for all clients. Cancel keeps them running." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer ces sessions arrête leurs processus distants pour tous les clients. Annuler les laisse s’exécuter." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了すると、すべてのクライアントのリモートプロセスが停止します。キャンセルすると実行を継続します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션을 종료하면 모든 클라이언트의 원격 프로세스가 중지됩니다. 취소하면 계속 실행됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结束这些会话会终止所有客户端上的远端进程。取消可使其继续运行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束這些工作階段會終止所有用戶端上的遠端程序。取消可讓其繼續執行。" + } + } + } + }, "Endpoint" : { "extractionState" : "manual", "localizations" : { @@ -40441,6 +41005,53 @@ } } }, + "New Managed Session" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neue verwaltete Sitzung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Managed Session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvelle session gérée" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理対象セッションを作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 관리 세션" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建托管会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增受管理工作階段" + } + } + } + }, "New Package" : { "extractionState" : "manual", "localizations" : { @@ -42911,6 +43522,53 @@ } } }, + "No remote sessions are running." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es laufen keine Remotesitzungen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No remote sessions are running." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune session distante en cours." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "実行中のリモートセッションはありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 중인 원격 세션이 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有正在运行的远端会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有執行中的遠端工作階段。" + } + } + } + }, "No results" : { "extractionState" : "manual", "localizations" : { @@ -44460,6 +45118,53 @@ } } }, + "On Connect" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Verbinden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On Connect" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À la connexion" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続時" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線時" + } + } + } + }, "Onboarding" : { "extractionState" : "manual", "localizations" : { @@ -49665,6 +50370,53 @@ } } }, + "Preparing the remote session timed out." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitüberschreitung beim Vorbereiten der Remotesitzung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing the remote session timed out." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La préparation de la session distante a expiré." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションの準備がタイムアウトしました。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션 준비 시간이 초과되었습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "准备远端会话超时。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備遠端工作階段逾時。" + } + } + } + }, "Preparing to run “%@”" : { "extractionState" : "manual", "localizations" : { @@ -52389,6 +53141,53 @@ } } }, + "Reattached to the existing remote session." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut an die bestehende Remotesitzung angehängt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reattached to the existing remote session." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rattaché à la session distante existante." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既存のリモートセッションに再アタッチしました。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 원격 세션에 다시 접속했습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已重新附着到原远端会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已重新附加至原遠端工作階段。" + } + } + } + }, "Reauthenticate" : { "extractionState" : "manual", "localizations" : { @@ -60940,6 +61739,53 @@ } } }, + "Session Persistence" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzungspersistenz" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session Persistence" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Persistance des sessions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションの永続化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 유지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话持久化" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作階段持續保留" + } + } + } + }, "Set baud rate" : { "extractionState" : "manual", "localizations" : { @@ -71039,6 +71885,53 @@ } } }, + "The remote session has ended." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Remotesitzung wurde beendet." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The remote session has ended." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session distante est terminée." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションが終了しました。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션이 종료되었습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远端会话已结束。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端工作階段已結束。" + } + } + } + }, "The remote shell did not return its current input." : { "extractionState" : "manual", "localizations" : { @@ -71203,6 +72096,53 @@ } } }, + "The saved remote session is missing or its identity changed." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die gespeicherte Remotesitzung fehlt oder ihre Identität hat sich geändert." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The saved remote session is missing or its identity changed." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session distante enregistrée est introuvable ou son identité a changé." + } + }, + "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" : { @@ -72094,6 +73034,53 @@ } } }, + "The session backend is not installed on this host." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Sitzungsbackend ist auf diesem Host nicht installiert." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The session backend is not installed on this host." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le backend de session n’est pas installé sur cet hôte." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このホストにセッションのバックエンドがインストールされていません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 호스트에 세션 백엔드가 설치되지 않았습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此主机尚未安装会话后端。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此主機尚未安裝工作階段後端。" + } + } + } + }, "The source file changed. Start a new transfer." : { "extractionState" : "manual", "localizations" : { @@ -74738,6 +75725,53 @@ } } }, + "This session backend or reference version is not supported." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieses Sitzungsbackend oder diese Referenzversion wird nicht unterstützt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This session backend or reference version is not supported." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce backend de session ou cette version de référence n’est pas pris en charge." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このセッションのバックエンドまたは参照バージョンには対応していません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 세션 백엔드 또는 참조 버전은 지원되지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不支持此会话后端或引用版本。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不支援此工作階段後端或參照版本。" + } + } + } + }, "This settings record requires sync format %lld." : { "extractionState" : "manual", "localizations" : { @@ -77138,6 +78172,53 @@ } } }, + "Unable to end the remote session. Retry, or disconnect without ending it." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Remotesitzung konnte nicht beendet werden. Versuche es erneut oder trenne die Verbindung, ohne die Sitzung zu beenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to end the remote session. Retry, or disconnect without ending it." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de terminer la session distante. Réessayez ou déconnectez-vous sans la terminer." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションを終了できません。再試行するか、セッションを終了せずに切断してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션을 종료할 수 없습니다. 다시 시도하거나 세션을 종료하지 않고 연결만 끊으세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法结束远端会话。请重试,或仅断开连接而不结束会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法結束遠端工作階段。請重試,或僅中斷連線而不結束工作階段。" + } + } + } + }, "Unable to generate a secure token." : { "extractionState" : "manual", "localizations" : { @@ -77302,6 +78383,53 @@ } } }, + "Unable to prepare the remote session." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Remotesitzung konnte nicht vorbereitet werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to prepare the remote session." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de préparer la session distante." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートセッションを準備できません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 세션을 준비할 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法准备远端会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法準備遠端工作階段。" + } + } + } + }, "Unable to preserve remote file permissions" : { "extractionState" : "manual", "localizations" : { @@ -79327,6 +80455,53 @@ } } }, + "Use a Normal Shell" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normale Shell verwenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use a Normal Shell" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser un shell normal" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通常のシェルを使用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일반 셸 사용" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用普通Shell" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用一般Shell" + } + } + } + }, "Use an installed skill" : { "comment" : "Command to use an installed skill.", "extractionState" : "manual", diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index cbca3540..d46ce501 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -359,6 +359,15 @@ public final class ConnectionJourney { ) } + public func waitForSessionSelection() { + guard phase == .connecting || phase == .waiting, !endpoints.isEmpty else { return } + checkpoint = nil + localRequirement = nil + localRequirementEndpointIndex = nil + activeEndpointIndex = endpoints.count - 1 + phase = .waiting + } + public func resume() { guard phase == .waiting else { return } phase = .connecting diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 784e853f..1fc8e955 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -2503,6 +2503,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { host.colorTag = preferences.colorTag host.sshCharset = preferences.charset host.sftpChannelInitialPath = preferences.sftpInitialPath + host.sessionPersistence = preferences.sessionPersistence host.shellIntegrationMode = preferences.shellIntegrationMode host.startupSnippetID = preferences.startupSnippetID host.environmentVariables = preferences.environmentVariables @@ -2530,6 +2531,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { preferences.colorTag = host.colorTag preferences.charset = host.sshCharset preferences.sftpInitialPath = host.sftpChannelInitialPath + preferences.sessionPersistence = host.sessionPersistence preferences.shellIntegrationMode = host.shellIntegrationMode preferences.startupSnippetID = host.startupSnippetID preferences.environmentVariables = host.environmentVariables diff --git a/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift b/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift new file mode 100644 index 00000000..05e70657 --- /dev/null +++ b/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift @@ -0,0 +1,371 @@ +import Foundation +import Observation +import TermindDomain + +public nonisolated struct RemoteSessionLaunch: Sendable { + public var command: String + public var attachment: RemoteSessionReference + public var created: Bool + + public init(command: String, attachment: RemoteSessionReference, created: Bool) { + self.command = command + self.attachment = attachment + self.created = created + } +} + +public nonisolated struct SSHPTYStartupPlan: Sendable { + public let command: String + public let shellEventToken: String? + public init(command: String, shellEventToken: String? = nil) { + self.command = command + self.shellEventToken = shellEventToken + } +} + +public nonisolated struct RemoteSessionCommandResult: Sendable { + public let stdout: String + public let stderr: String + public let exitCode: Int32 + public let truncated: Bool + public init(stdout: String, stderr: String, exitCode: Int32, truncated: Bool = false) { + self.stdout = stdout + self.stderr = stderr + self.exitCode = exitCode + self.truncated = truncated + } +} + +public typealias RemoteSessionCommandExecutor = @Sendable (String) async throws -> RemoteSessionCommandResult + +public nonisolated struct RemoteSessionCapabilities: Sendable, Equatable { + public var windowCount: Bool + public var clientCount: Bool + public var shellIntegration: Bool + public init(windowCount: Bool = false, clientCount: Bool = false, shellIntegration: Bool = false) { + self.windowCount = windowCount + self.clientCount = clientCount + self.shellIntegration = shellIntegration + } +} + +public nonisolated struct RemoteSessionBackendRegistry: Sendable { + public typealias Factory = @Sendable (@escaping RemoteSessionCommandExecutor) -> any RemoteSessionBackend + private let factories: [SessionPersistenceBackendID: Factory] + public init(_ factories: [SessionPersistenceBackendID: Factory]) { self.factories = factories } + public func backend(_ id: SessionPersistenceBackendID, execute: @escaping RemoteSessionCommandExecutor) throws + -> any RemoteSessionBackend + { + guard let factory = factories[id] else { throw RemoteSessionFailure.unsupported } + return factory(execute) + } +} + +public nonisolated protocol RemoteSessionBackend: Sendable { + var id: SessionPersistenceBackendID { get } + var capabilities: RemoteSessionCapabilities { get } + func probe() async throws -> RemoteSessionCapabilities + func sessions() async throws -> [RemoteSessionDescriptor] + func validate(_ reference: RemoteSessionReference) throws + func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool + func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] + ) async throws -> RemoteSessionDescriptor + func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch + func terminate(_ attachment: RemoteSessionReference) async throws +} + +extension RemoteSessionBackend { + public nonisolated func probe() async throws -> RemoteSessionCapabilities { capabilities } + public nonisolated func validate(_ reference: RemoteSessionReference) throws { + guard reference.version == 1, reference.backend == id else { throw RemoteSessionFailure.unsupported } + } +} + +@MainActor +@Observable +public final class RemoteSessionCoordinator { + public nonisolated struct Prompt: Identifiable, Sendable { + public let id: UUID + public var sessions: [RemoteSessionDescriptor] + public var failure: RemoteSessionFailure? + } + + public nonisolated enum Selection: Sendable { + case create + case attach(RemoteSessionDescriptor) + case plainShell + case retry + case cancel + } + + public private(set) var prompt: Prompt? { + didSet { didChangePrompt?(prompt != nil) } + } + public var didChangePrompt: (@MainActor (Bool) -> Void)? + public var attachment: RemoteSessionReference? + public private(set) var detached = false + public var requiresSelection = false + public var didChangeAttachment: (@MainActor (RemoteSessionReference?) -> Void)? + private var generation = UUID() + private var continuation: CheckedContinuation? + private var creationToken: UUID? + private var creationIntent: CreationIntent? + + private struct CreationIntent: Equatable { + let backend: SessionPersistenceBackendID + let hostID: UUID + let endpoint: String + let username: String + let command: String? + let shellIntegration: Bool + let environment: [HostEnvironmentVariable] + } + + public init() {} + + public func choose(_ selection: Selection, for id: UUID) { + guard prompt?.id == id else { return } + prompt = nil + let pending = continuation + continuation = nil + pending?.resume(returning: selection) + } + + public func cancel() { + generation = UUID() + if let prompt { choose(.cancel, for: prompt.id) } + } + + public func cancelSelection(for id: UUID, close: @MainActor () -> Void) { + guard prompt?.id == id else { return } + // Cancelling preparation abandons this connection, not a remote session we have not attached to. + clearAttachment() + choose(.cancel, for: id) + // Close synchronously, so the connection generation expires before cancellation is reported. + close() + } + + public func markDetached() { detached = true } + + /// Only a verified, successful session end closes the local terminal. + /// A failed inspection preserves the recovery reference, just like detach. + public func finishExit(exitCode: Int32, sessionExists: Bool?) -> Bool { + if sessionExists == false { + markEnded() + return exitCode == 0 + } + markDetached() + return false + } + + public func markEnded() { + detached = false + requiresSelection = true + creationToken = nil + clearAttachment() + } + + public func clearAttachment() { + attachment = nil + didChangeAttachment?(nil) + } + + public func confirmPlainConnection() async throws { + cancel() + let attempt = generation + let selection = await ask(sessions: [], failure: .unsupported, generation: attempt) + try check(attempt) + guard case .plainShell = selection else { throw CancellationError() } + requiresSelection = false + creationToken = nil + clearAttachment() + } + + public func prepare( + preferences: SessionPersistencePreferences, registry: RemoteSessionBackendRegistry, + execute: @escaping RemoteSessionCommandExecutor, + hostID: UUID, endpoint: String, username: String, + startupCommand: String?, requiresNewSession: Bool, shellIntegration: Bool, + environment: [HostEnvironmentVariable] = [] + ) async throws -> SSHPTYStartupPlan? { + cancel() + if attachment == nil, !requiresSelection, + !preferences.enabled || preferences.onConnect == .plainShell + { + creationToken = nil + return nil + } + if !preferences.enabled, attachment == nil { + try await confirmPlainConnection() + return nil + } + while true { + let service: any RemoteSessionBackend + do { + service = try registry.backend(attachment?.backend ?? preferences.backend, execute: execute) + } catch { + let attempt = generation + let selection = await ask(sessions: [], failure: .unsupported, generation: attempt) + try check(attempt) + switch selection { + case .plainShell: + clearAttachment() + creationToken = nil + requiresSelection = false + return nil + case .retry: continue + default: throw CancellationError() + } + } + let launch = try await prepare( + service: service, hostID: hostID, endpoint: endpoint, username: username, + startupCommand: startupCommand, requiresNewSession: requiresNewSession, + shellIntegration: shellIntegration, onConnect: preferences.onConnect, environment: environment) + return launch.map { SSHPTYStartupPlan(command: $0.command) } + } + } + + public func prepare( + service: any RemoteSessionBackend, hostID: UUID, endpoint: String, username: String, + startupCommand: String?, requiresNewSession: Bool, shellIntegration: Bool, + onConnect: SessionPersistencePreferences.OnConnect = .ask, + environment: [HostEnvironmentVariable] = [] + ) async throws -> RemoteSessionLaunch? { + cancel() + let attempt = generation + detached = false + let intent = CreationIntent( + backend: service.id, hostID: hostID, endpoint: endpoint, username: username, + command: startupCommand, shellIntegration: shellIntegration, environment: environment) + if creationIntent != intent { + // An uncertain create may be retried, but its ownership capability must never + // attach a different launch request to an earlier session. + creationToken = nil + creationIntent = intent + } + if attachment == nil, !requiresSelection, onConnect == .plainShell { + creationToken = nil + return nil + } + var mustAsk = requiresSelection + while true { + try check(attempt) + var sessions: [RemoteSessionDescriptor] = [] + var capabilities = service.capabilities + var failure: RemoteSessionFailure? + do { + if let saved = attachment, !requiresNewSession { try service.validate(saved) } + capabilities = try await service.probe() + try check(attempt) + sessions = try await service.sessions() + try check(attempt) + if let saved = attachment, !requiresNewSession { + if saved.version == 1, saved.backend == service.id, + saved.hostID == hostID, saved.endpoint == endpoint, saved.username == username, + let current = sessions.first(where: { service.matches(saved, session: $0) }) + { + var updated = saved + updated.session = current + attachment = updated + didChangeAttachment?(updated) + return try service.launch(updated, created: false) + } + attachment = nil + didChangeAttachment?(nil) + requiresSelection = true + mustAsk = true + failure = saved.version == 1 ? .sessionMissing : .unsupported + } + } catch is CancellationError { + throw CancellationError() + } catch { + failure = error as? RemoteSessionFailure ?? .commandFailed(error.localizedDescription) + } + try check(attempt) + let selection: Selection + if !mustAsk, requiresNewSession || onConnect == .createManaged, failure == nil { + selection = .create + } else { + selection = await ask(sessions: sessions, failure: failure, generation: attempt) + } + try check(attempt) + switch selection { + case .cancel: throw CancellationError() + case .retry: continue + case .plainShell: + attachment = nil + creationToken = nil + requiresSelection = false + didChangeAttachment?(nil) + return nil + case .create, .attach: + do { + let session: RemoteSessionDescriptor + let token: UUID? + if case .attach(let existing) = selection { + guard sessions.contains(existing), !requiresNewSession else { continue } + session = existing + token = nil + } else { + let owner = creationToken ?? UUID() + creationToken = owner + session = try await service.create( + token: owner, startupCommand: startupCommand, + shellIntegration: shellIntegration && capabilities.shellIntegration, + environment: environment) + token = owner + } + try check(attempt) + let reference = RemoteSessionReference( + backend: service.id, session: session, hostID: hostID, + endpoint: endpoint, username: username, ownershipToken: token) + let result = try service.launch(reference, created: token != nil) + attachment = reference + creationToken = nil + requiresSelection = false + didChangeAttachment?(reference) + return result + } catch is CancellationError { + throw CancellationError() + } catch { + try check(attempt) + let choice = await ask( + sessions: [], + failure: error as? RemoteSessionFailure ?? .commandFailed(error.localizedDescription), + generation: attempt) + try check(attempt) + if case .plainShell = choice { + attachment = nil + creationToken = nil + requiresSelection = false + didChangeAttachment?(nil) + return nil + } + if case .retry = choice { continue } + throw CancellationError() + } + } + } + } + + private func check(_ attempt: UUID) throws { + try Task.checkCancellation() + guard attempt == generation else { throw CancellationError() } + } + + private func ask(sessions: [RemoteSessionDescriptor], failure: RemoteSessionFailure?, generation: UUID) async + -> Selection + { + let id = UUID() + return await withTaskCancellationHandler { + guard !Task.isCancelled, self.generation == generation else { return .cancel } + return await withCheckedContinuation { + continuation = $0 + prompt = Prompt(id: id, sessions: sessions, failure: failure) + } + } onCancel: { + Task { @MainActor [weak self] in self?.choose(.cancel, for: id) } + } + } +} diff --git a/Frameworks/TermindApplication/Terminal/RemoteSessionCloseCoordinator.swift b/Frameworks/TermindApplication/Terminal/RemoteSessionCloseCoordinator.swift new file mode 100644 index 00000000..48537631 --- /dev/null +++ b/Frameworks/TermindApplication/Terminal/RemoteSessionCloseCoordinator.swift @@ -0,0 +1,84 @@ +import Foundation +import Observation +import TermindDomain + +@MainActor +@Observable +public final class RemoteSessionCloseCoordinator { + public struct Prompt: Identifiable { + public let id = UUID() + public let names: [String] + public let failure: String? + } + + public enum Choice { case cancel, endSessions, disconnectOnly } + public private(set) var prompt: Prompt? + public private(set) var isClosing = false + private var continuation: CheckedContinuation? + private var generation = UUID() + + public init() {} + + public func cancel() { + generation = UUID() + if let prompt { choose(.cancel, for: prompt.id) } + } + + public func choose(_ choice: Choice, for id: UUID) { + guard prompt?.id == id else { return } + prompt = nil + let pending = continuation + continuation = nil + pending?.resume(returning: choice) + } + + /// A failed termination never grants permission to remove the workspace. + public func close( + attachments: [RemoteSessionReference], intent: TerminalTeardownIntent = .explicitClose, + terminate: @MainActor (RemoteSessionReference) async throws -> Void + ) async -> Bool { + guard intent.endsManagedSession else { return true } + let managed = attachments.filter(\.isManaged).reduce(into: [RemoteSessionReference]()) { unique, reference in + if !unique.contains(where: { $0.hasSameIdentity(as: reference) }) { unique.append(reference) } + } + guard !managed.isEmpty else { return true } + guard !isClosing else { return false } + let attempt = generation + isClosing = true + defer { isClosing = false } + var remaining = managed + var failure: String? + while !remaining.isEmpty { + guard generation == attempt, !Task.isCancelled else { return false } + if let failure { + let request = Prompt(names: remaining.map(\.session.name), failure: failure) + let choice = await withTaskCancellationHandler { + await withCheckedContinuation { + continuation = $0 + prompt = request + if Task.isCancelled { choose(.cancel, for: request.id) } + } + } onCancel: { + Task { @MainActor [weak self] in self?.choose(.cancel, for: request.id) } + } + switch choice { + case .cancel: return false + case .disconnectOnly: return generation == attempt && !Task.isCancelled + case .endSessions: break + } + } + do { + while let attachment = remaining.first { + try Task.checkCancellation() + guard generation == attempt else { return false } + try await terminate(attachment) + remaining.removeFirst() + } + guard generation == attempt, !Task.isCancelled else { return false } + } catch { + failure = error.localizedDescription + } + } + return true + } +} diff --git a/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift new file mode 100644 index 00000000..79af98f8 --- /dev/null +++ b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift @@ -0,0 +1,151 @@ +import Foundation +import Observation +import TermindDomain + +public nonisolated enum TerminalRuntimeError: Error, Sendable { + case capacityUnavailable, unsupported, invalidResponse, staleContext +} + +/// Implementations may only use the terminal's existing authenticated transport. +public nonisolated protocol TerminalRuntimeAdapter: Sendable { + func inspect(terminal: String) async throws -> TerminalRuntimeUpdate +} + +@MainActor @Observable +public final class TerminalContextCoordinator { + public struct Ticket: Equatable, Sendable { + public let generation: UUID + public let revision: UInt64 + public let context: TerminalContextIdentity + } + + public private(set) var generation = UUID() + public private(set) var revision: UInt64 = 0 + public private(set) var status: TerminalRuntimeUpdate.Status = .unknown + public private(set) var active: TerminalShellContext? + public private(set) var contexts: [TerminalContextIdentity: TerminalShellContext] = [:] + public private(set) var clientIdentity: String? + private var sequences: [TerminalContextIdentity: UInt64] = [:] + private var running: Set = [] + private var inspectionID: UUID? + + public init() {} + + public var directory: String? { + guard let active, active.capabilities.contains(.directory) else { return nil } + return active.directory + } + + public func reset() { + generation = UUID() + revision &+= 1 + status = .unknown + active = nil + clientIdentity = nil + contexts.removeAll() + sequences.removeAll() + running.removeAll() + inspectionID = nil + } + + public func invalidate() { + revision &+= 1 + active = nil + status = .unknown + } + + public func noteInput() { revision &+= 1 } + + public func ticket(requiring capability: TerminalContextCapabilities) -> Ticket? { + guard let active, active.promptReady, active.capabilities.contains(capability) else { return nil } + return Ticket(generation: generation, revision: revision, context: active.identity) + } + + public func isCurrent(_ ticket: Ticket, requiring capability: TerminalContextCapabilities) -> Bool { + self.ticket(requiring: capability) == ticket + } + + public func refresh(using adapter: any TerminalRuntimeAdapter, terminal: String) async throws { + let id = UUID() + inspectionID = id + let generation = generation + let revision = revision + let update = try await adapter.inspect(terminal: terminal) + try Task.checkCancellation() + guard inspectionID == id, self.generation == generation, self.revision == revision else { + throw TerminalRuntimeError.staleContext + } + receive(update, generation: generation) + } + + /// Returns only new, contiguous events. A snapshot never invents a command start. + @discardableResult + public func receive(_ update: TerminalRuntimeUpdate, generation expected: UUID) -> [TerminalContextEvent] { + guard expected == generation else { return [] } + if let active = update.active, + let snapshot = update.contexts.first(where: { $0.identity == active }), + let last = sequences[active], snapshot.sequence < last + { + return [] + } + var accepted: [TerminalContextEvent] = [] + var interrupted: Set = [] + if update.status == .multiplexer { + for context in update.contexts where sequences[context.identity] == nil { + sequences[context.identity] = context.sequence + } + } + for event in update.events { + guard let source = update.contexts.first(where: { $0.identity == event.context }), + event.sequence <= source.sequence + else { continue } + let last = sequences[event.context] + guard last == nil || event.sequence > last! else { continue } + let contiguous = last.map { $0 < UInt64.max && event.sequence == $0 + 1 } ?? (event.sequence == 1) + if !contiguous { + running.remove(event.context) + interrupted.insert(event.context) + } + sequences[event.context] = event.sequence + switch event.payload { + case .commandStarted: + guard source.capabilities.contains(.commandLifecycle) else { continue } + running.insert(event.context) + accepted.append(event) + case .commandFinished: + guard source.capabilities.contains(.commandLifecycle) else { continue } + if running.remove(event.context) != nil, contiguous { accepted.append(event) } + default: + accepted.append(event) + } + } + let next = Dictionary(update.contexts.map { ($0.identity, $0) }, uniquingKeysWith: { _, new in new }) + for context in update.contexts { + // Establish a high-water mark when joining an already-running shell. + if sequences[context.identity] == nil { sequences[context.identity] = context.sequence } + } + let nextActive = + update.status == .ordinary || update.status == .multiplexer ? update.active.flatMap { next[$0] } : nil + let activeInteractionChanged = accepted.contains { event in + guard event.context == nextActive?.identity || event.context == active?.identity else { return false } + if case .draft = event.payload { return false } + return true + } + if status != update.status || clientIdentity != update.clientIdentity + || active?.identity != nextActive?.identity || active?.promptReady != nextActive?.promptReady + || active?.directory != nextActive?.directory || active?.capabilities != nextActive?.capabilities + || activeInteractionChanged + || nextActive.map({ interrupted.contains($0.identity) }) == true + { + revision &+= 1 + } + status = update.status + clientIdentity = update.clientIdentity + active = nextActive + contexts = next + if update.status == .multiplexer { + running = running.filter { $0.backend == "shell" || next[$0] != nil } + } + return accepted + } +} diff --git a/Frameworks/TermindApplication/TerminalCommandMonitor.swift b/Frameworks/TermindApplication/TerminalCommandMonitor.swift index 864ea5d5..dc28b85c 100644 --- a/Frameworks/TermindApplication/TerminalCommandMonitor.swift +++ b/Frameworks/TermindApplication/TerminalCommandMonitor.swift @@ -35,6 +35,8 @@ public final class TerminalCommandMonitor { private var active = false private var usesShellIntegration = false private var running: (command: String, started: TimeInterval)? + private var contextualRunning: [TerminalContextIdentity: (command: String, started: TimeInterval)] = [:] + private var acceptsUnscopedCompletion = true public init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { self.now = now @@ -44,12 +46,15 @@ public final class TerminalCommandMonitor { active = true usesShellIntegration = false running = nil + contextualRunning.removeAll() + acceptsUnscopedCompletion = true } public func endConnection() { active = false running = nil usesShellIntegration = false + contextualRunning.removeAll() } public func markRead() { unreadCompletionCount = 0 } @@ -74,10 +79,32 @@ public final class TerminalCommandMonitor { } public func receiveOSC133(exitCode: Int?, durationNanos: UInt64) { - guard active, !usesShellIntegration else { return } + guard active, acceptsUnscopedCompletion, !usesShellIntegration else { return } complete(.init(exitCode: exitCode, duration: Double(durationNanos) / 1_000_000_000)) } + public func requireScopedEvents() { + acceptsUnscopedCompletion = false + } + + public func receiveRuntimeEvents(_ events: [TerminalContextEvent]) { + guard active else { return } + acceptsUnscopedCompletion = false + for event in events { + switch event.payload { + case .commandStarted(let command): + contextualRunning[event.context] = (command, now()) + case .commandFinished(let status, let reported): + guard let command = contextualRunning.removeValue(forKey: event.context) else { continue } + let duration = + reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil } + ?? max(0, now() - command.started) + complete(.init(command: command.command, exitCode: Int(status), duration: duration)) + default: break + } + } + } + private func complete(_ completion: TerminalCommandCompletion) { let focused = isWorkspaceFocused() unreadCompletionCount = focused ? 0 : unreadCompletionCount + 1 diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index 087786a9..24d28d5c 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -155,6 +155,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public var colorTag: TagColor = .none public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings /// Allows the destination to request signatures from this device's Vault keys. + public var sessionPersistence = SessionPersistencePreferences() public var agentForwardingEnabled = false /// A reusable script submitted after each interactive SSH shell becomes ready. /// The body is resolved from the Snippet library for every connection attempt. @@ -279,6 +280,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0, shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings, startupSnippetID: Snippet.ID? = nil, agentForwardingEnabled: Bool = false, + sessionPersistence: SessionPersistencePreferences = .init(), environmentVariables: [HostEnvironmentVariable] = [], portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections ) { @@ -305,6 +307,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { self.shellIntegrationMode = shellIntegrationMode self.startupSnippetID = startupSnippetID self.agentForwardingEnabled = agentForwardingEnabled + self.sessionPersistence = sessionPersistence self.environmentVariables = environmentVariables self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( portForwardingMaxConnections) @@ -316,7 +319,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { case charset case sshEnabled, telnetEnabled, telnetPort, telnetIdentityID, telnetCharset, telnetProxy case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode - case agentForwardingEnabled, startupSnippetID, environmentVariables, portForwardingMaxConnections + case sessionPersistence, agentForwardingEnabled, startupSnippetID, environmentVariables, + portForwardingMaxConnections } public init(from decoder: any Decoder) throws { @@ -349,6 +353,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { shellIntegrationMode = try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode) .flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings + sessionPersistence = + try values.decodeIfPresent(SessionPersistencePreferences.self, forKey: .sessionPersistence) + ?? .init(enabled: false) agentForwardingEnabled = try values.decodeIfPresent(Bool.self, forKey: .agentForwardingEnabled) ?? false startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID) environmentVariables = @@ -382,6 +389,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { try values.encodeIfPresent(detectedDistribution, forKey: .detectedDistribution) try values.encode(sortIndex, forKey: .sortIndex) try values.encode(shellIntegrationMode.rawValue, forKey: .shellIntegrationMode) + try values.encode(sessionPersistence, forKey: .sessionPersistence) try values.encode(agentForwardingEnabled, forKey: .agentForwardingEnabled) try values.encodeIfPresent(startupSnippetID, forKey: .startupSnippetID) try values.encode(environmentVariables, forKey: .environmentVariables) diff --git a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift index 9ecb112c..d8001521 100644 --- a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift +++ b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift @@ -95,13 +95,15 @@ public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable public var username = "" public var charset: RemoteCharset = .utf8 public var sftpInitialPath = "" + public var sessionPersistence = SessionPersistencePreferences() public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings public var startupSnippetID: UUID? public var environmentVariables: [HostEnvironmentVariable] = [] public init() {} private enum CodingKeys: String, CodingKey { - case tags, colorTag, username, charset, sftpInitialPath, shellIntegrationMode, startupSnippetID, + case sessionPersistence, tags, colorTag, username, charset, sftpInitialPath, shellIntegrationMode, + startupSnippetID, environmentVariables, detectedDistribution } @@ -113,6 +115,9 @@ 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) + sessionPersistence = + try values.decodeIfPresent(SessionPersistencePreferences.self, forKey: .sessionPersistence) + ?? .init(enabled: false) shellIntegrationMode = try values.decode(HostShellIntegrationMode.self, forKey: .shellIntegrationMode) startupSnippetID = try values.decodeIfPresent(UUID.self, forKey: .startupSnippetID) environmentVariables = try values.decode([HostEnvironmentVariable].self, forKey: .environmentVariables) diff --git a/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift b/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift index c51666cf..e708f0c8 100644 --- a/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift +++ b/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift @@ -1,6 +1,7 @@ import Foundation public nonisolated enum SSHShellIntegrationEvent: Sendable { + case runtime(TerminalRuntimeUpdate) case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?) case systemInfo(RemoteSystemInfo) case homeResolutionUnavailable diff --git a/Frameworks/TermindDomain/Sessions/SessionPersistence.swift b/Frameworks/TermindDomain/Sessions/SessionPersistence.swift new file mode 100644 index 00000000..e8a8a630 --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/SessionPersistence.swift @@ -0,0 +1,77 @@ +import Foundation + +public nonisolated struct SessionPersistenceBackendID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public static let tmux = Self(rawValue: "tmux") +} + +public nonisolated struct SessionPersistencePreferences: Codable, Hashable, Sendable { + public enum OnConnect: String, Codable, CaseIterable, Sendable { + case ask, createManaged, plainShell + } + public var enabled: Bool + public var backend: SessionPersistenceBackendID + public var onConnect: OnConnect + public init(enabled: Bool = false, backend: SessionPersistenceBackendID = .tmux, onConnect: OnConnect = .ask) { + self.enabled = enabled + self.backend = backend + self.onConnect = onConnect + } +} + +public nonisolated struct RemoteSessionDescriptor: Codable, Hashable, Identifiable, Sendable { + public let id: String + public let name: String + /// Versioned backend data, never a command. Only that backend interprets it. + public let identity: Data + public let windows: Int? + public let clients: Int? + public init(id: String, name: String, identity: Data, windows: Int? = nil, clients: Int? = nil) { + self.id = id + self.name = name + self.identity = identity + self.windows = windows + self.clients = clients + } +} + +/// A local recovery reference. URL input must never grant managed ownership. +public nonisolated struct RemoteSessionReference: Codable, Hashable, Sendable { + public var version: Int = 1 + public var backend: SessionPersistenceBackendID + public var session: RemoteSessionDescriptor + public let hostID: UUID + public let endpoint: String + public let username: String + public let ownershipToken: UUID? + public init( + backend: SessionPersistenceBackendID, session: RemoteSessionDescriptor, + hostID: UUID, endpoint: String, username: String, ownershipToken: UUID? + ) { + self.backend = backend + self.session = session + self.hostID = hostID + self.endpoint = endpoint + self.username = username + self.ownershipToken = ownershipToken + } + public var isManaged: Bool { ownershipToken != nil } + + /// Presentation metadata (name and counts) can change without changing the recovery capability. + public func hasSameIdentity(as other: Self) -> Bool { + version == other.version && backend == other.backend && hostID == other.hostID + && endpoint == other.endpoint && username == other.username && ownershipToken == other.ownershipToken + && session.id == other.session.id && session.identity == other.session.identity + } +} + +public nonisolated enum TerminalTeardownIntent: Sendable { + case explicitClose, applicationTermination, suspension, remoteExit, detach + public var endsManagedSession: Bool { self == .explicitClose } +} + +public nonisolated enum RemoteSessionFailure: Error, Equatable, Sendable { + case missing, unsupported, timedOut, invalidResponse, sessionMissing, ownershipMismatch + case commandFailed(String) +} diff --git a/Frameworks/TermindDomain/Sessions/TerminalContext.swift b/Frameworks/TermindDomain/Sessions/TerminalContext.swift new file mode 100644 index 00000000..a51f4967 --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalContext.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Runtime identity, deliberately unrelated to session ownership or recovery. +public nonisolated struct TerminalContextIdentity: Hashable, Sendable { + public let backend: String + public let server: String + public let session: String + public let pane: String + public let shellInstance: String + + public init(backend: String, server: String, session: String, pane: String, shellInstance: String) { + self.backend = backend + self.server = server + self.session = session + self.pane = pane + self.shellInstance = shellInstance + } +} + +public nonisolated struct TerminalContextCapabilities: OptionSet, Sendable, Equatable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + public static let directory = Self(rawValue: 1 << 0) + public static let commandLifecycle = Self(rawValue: 1 << 1) + public static let readDraft = Self(rawValue: 1 << 2) + public static let replaceDraft = Self(rawValue: 1 << 3) +} + +public nonisolated struct TerminalShellContext: Sendable, Equatable { + public let identity: TerminalContextIdentity + public var shell: String + public var directory: String? + public var home: String? + public var historyPath: String? + public var promptReady: Bool + public var sequence: UInt64 + public var capabilities: TerminalContextCapabilities + + public init( + identity: TerminalContextIdentity, shell: String, directory: String?, home: String?, + promptReady: Bool, sequence: UInt64, capabilities: TerminalContextCapabilities, historyPath: String? = nil + ) { + self.identity = identity + self.shell = shell + self.directory = directory + self.home = home + self.historyPath = historyPath + self.promptReady = promptReady + self.sequence = sequence + self.capabilities = capabilities + } +} + +public nonisolated struct TerminalContextEvent: Sendable, Equatable { + public enum Payload: Sendable, Equatable { + case commandStarted(String) + case commandFinished(Int32, duration: TimeInterval?) + case promptReady + case directory(String) + case draft(request: String, value: String) + } + public let context: TerminalContextIdentity + public let sequence: UInt64 + public let payload: Payload + + public init(context: TerminalContextIdentity, sequence: UInt64, payload: Payload) { + self.context = context + self.sequence = sequence + self.payload = payload + } +} + +public nonisolated struct TerminalRuntimeUpdate: Sendable, Equatable { + public enum Status: Sendable, Equatable { + case ordinary, multiplexer, unknown, unavailable + } + public var status: Status + public var clientIdentity: String? + public var active: TerminalContextIdentity? + public var contexts: [TerminalShellContext] + public var events: [TerminalContextEvent] + + public init( + status: Status, clientIdentity: String? = nil, active: TerminalContextIdentity? = nil, + contexts: [TerminalShellContext] = [], events: [TerminalContextEvent] = [] + ) { + self.status = status + self.clientIdentity = clientIdentity + self.active = active + self.contexts = contexts + self.events = events + } +} diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index a2219a37..4ad3876e 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -44,8 +44,8 @@ public nonisolated struct SyncRecordFormats: Sendable { private static let v1 = Format(readingVersions: [1], writingVersion: 1) public static let vault = Self([ - "host": Format(readingVersions: [1, 2], writingVersion: 2), - "hostGroup": Format(readingVersions: [1, 2, 3], writingVersion: 3), "identity": v1, + "host": Format(readingVersions: [1, 2, 4], writingVersion: 4), + "hostGroup": Format(readingVersions: [1, 2, 3, 4], writingVersion: 4), "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, diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index ac16d5bc..0700572c 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -958,6 +958,14 @@ public enum TermindSchemaV52: VersionedSchema { } } +public enum TermindSchemaV53: VersionedSchema { + public static let versionIdentifier = Schema.Version(53, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV52.models.filter { $0 != TermindSchemaV44HostModels.PersistentHost.self } + + [TermindSchemaV53HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -974,7 +982,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self, TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self, TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self, - TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, + TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, TermindSchemaV53.self, ] } @@ -1086,11 +1094,12 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV49.self, toVersion: TermindSchemaV50.self), MigrationStage.lightweight(fromVersion: TermindSchemaV50.self, toVersion: TermindSchemaV51.self), MigrationStage.lightweight(fromVersion: TermindSchemaV51.self, toVersion: TermindSchemaV52.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV52.self, toVersion: TermindSchemaV53.self), ] } } -public typealias PersistentHost = TermindSchemaV44HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV53HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV33IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV45KeyModels.PersistentSSHKey public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 1026cb67..ba8ec067 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -20,7 +20,7 @@ public enum TermindPersistence { #endif // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV52.self) + public static let schema = Schema(versionedSchema: TermindSchemaV53.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV53HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV53HostModels.swift new file mode 100644 index 00000000..f1dec139 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV53HostModels.swift @@ -0,0 +1,136 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +public enum TermindSchemaV53HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var sshEnabled = true + 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 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 + 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 + 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) + }, + 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.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let telnetProxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: telnetProxyProtocol) { + host.telnetProxy = HostProxy( + kind: kind, hostname: telnetProxyHostname, port: telnetProxyPort, + username: telnetProxyUsername, identityID: telnetProxyIdentityID) + } + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index a023b9b3..d9a6539c 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -66,6 +66,21 @@ public nonisolated enum VaultSyncCodec { guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + if envelope.formatVersion < 4 { + if case .host(let host) = envelope.payload, host.sessionPersistence != .init(enabled: false) { + throw CocoaError(.coderInvalidValue) + } + if case .hostGroup(let group) = envelope.payload, + group.tailscale?.hostPreferences.values.contains(where: { + $0.sessionPersistence != .init(enabled: false) + }) == true + { + throw CocoaError(.coderInvalidValue) + } + } + if envelope.formatVersion == 4 { + return try encoder.encode(VaultWireV4.Envelope(envelope)) + } if envelope.formatVersion == 3 { return try encoder.encode(VaultWireV3.Envelope(envelope)) } @@ -98,6 +113,9 @@ public nonisolated enum VaultSyncCodec { decoders[VaultRecordKind.hostGroup.rawValue]?[3] = { try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue } + for kind in [VaultRecordKind.host, .hostGroup] { + decoders[kind.rawValue]?[4] = { try JSONDecoder().decode(VaultWireV4.Envelope.self, from: $0).domainValue } + } return VersionedRecordDecoder(decoders) }() diff --git a/Frameworks/TermindPersistence/VaultWireV1.swift b/Frameworks/TermindPersistence/VaultWireV1.swift index 8f7bddea..2e98be0b 100644 --- a/Frameworks/TermindPersistence/VaultWireV1.swift +++ b/Frameworks/TermindPersistence/VaultWireV1.swift @@ -213,7 +213,7 @@ nonisolated enum VaultWireV1 { self.sortIndex = value.sortIndex } var domainValue: TermindDomain.Host { - var result = TermindDomain.Host(name: "", host: "") + var result = TermindDomain.Host(name: "", host: "", sessionPersistence: .init(enabled: false)) result.id = id result.name = name result.host = host diff --git a/Frameworks/TermindPersistence/VaultWireV3.swift b/Frameworks/TermindPersistence/VaultWireV3.swift index 7b528ec5..79f7737c 100644 --- a/Frameworks/TermindPersistence/VaultWireV3.swift +++ b/Frameworks/TermindPersistence/VaultWireV3.swift @@ -3,21 +3,117 @@ import TermindDomain /// V3 preserves group-scoped Tailscale identities and keys whose algorithm is not yet known. nonisolated enum VaultWireV3 { + struct TailscalePreferences: Codable { + var tags: [String] + var colorTag: TermindDomain.TagColor + var detectedDistribution: DetectedDistribution? + var username: String + var charset: RemoteCharset + var sftpInitialPath: String + var shellIntegrationMode: HostShellIntegrationMode + var startupSnippetID: UUID? + var environmentVariables: [HostEnvironmentVariable] + + init(_ value: TermindDomain.TailscaleHostPreferences) { + tags = value.tags + colorTag = value.colorTag + detectedDistribution = value.detectedDistribution + username = value.username + charset = value.charset + sftpInitialPath = value.sftpInitialPath + shellIntegrationMode = value.shellIntegrationMode + startupSnippetID = value.startupSnippetID + environmentVariables = value.environmentVariables + } + private enum CodingKeys: String, CodingKey { + case tags, colorTag, detectedDistribution, username, charset, sftpInitialPath + case shellIntegrationMode, startupSnippetID, environmentVariables + } + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + tags = try values.decodeIfPresent([String].self, forKey: .tags) ?? [] + colorTag = try values.decodeIfPresent(TermindDomain.TagColor.self, forKey: .colorTag) ?? .none + detectedDistribution = try values.decodeIfPresent(DetectedDistribution.self, forKey: .detectedDistribution) + username = try values.decode(String.self, forKey: .username) + charset = try values.decode(RemoteCharset.self, forKey: .charset) + sftpInitialPath = try values.decode(String.self, forKey: .sftpInitialPath) + shellIntegrationMode = try values.decode(HostShellIntegrationMode.self, forKey: .shellIntegrationMode) + startupSnippetID = try values.decodeIfPresent(UUID.self, forKey: .startupSnippetID) + environmentVariables = try values.decode([HostEnvironmentVariable].self, forKey: .environmentVariables) + } + var domainValue: TermindDomain.TailscaleHostPreferences { + var value = TermindDomain.TailscaleHostPreferences() + value.sessionPersistence = .init(enabled: false) + value.tags = tags + value.colorTag = colorTag + value.detectedDistribution = detectedDistribution + value.username = username + value.charset = charset + value.sftpInitialPath = sftpInitialPath + value.shellIntegrationMode = shellIntegrationMode + value.startupSnippetID = startupSnippetID + value.environmentVariables = environmentVariables + return value + } + } + struct TailscaleConfiguration: Codable { + var network: TailscaleNetwork + var hostIdentityVersion: TailscaleGroupConfiguration.HostIdentityVersion + var username: String + var exitNodeID: String? + var hostPreferences: [String: TailscalePreferences] + init(_ value: TailscaleGroupConfiguration) { + network = value.network + hostIdentityVersion = value.hostIdentityVersion + username = value.username + exitNodeID = value.exitNodeID + hostPreferences = Dictionary( + uniqueKeysWithValues: value.hostPreferences.map { + ($0.key.uuidString.lowercased(), TailscalePreferences($0.value)) + }) + } + private enum CodingKeys: String, CodingKey { + case network, hostIdentityVersion, username, exitNodeID, hostPreferences + } + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + network = try values.decode(TailscaleNetwork.self, forKey: .network) + hostIdentityVersion = try values.decode( + TailscaleGroupConfiguration.HostIdentityVersion.self, forKey: .hostIdentityVersion) + username = try values.decode(String.self, forKey: .username) + exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID) + hostPreferences = try values.decode([String: TailscalePreferences].self, forKey: .hostPreferences) + guard hostPreferences.keys.allSatisfy({ UUID(uuidString: $0)?.uuidString.lowercased() == $0 }) else { + throw DecodingError.dataCorruptedError( + forKey: .hostPreferences, in: values, debugDescription: "Invalid host identifier") + } + } + var domainValue: TailscaleGroupConfiguration { + var value = TailscaleGroupConfiguration(network: network, hostIdentityVersion: hostIdentityVersion) + value.username = username + value.exitNodeID = exitNodeID + value.hostPreferences = Dictionary( + uniqueKeysWithValues: hostPreferences.compactMap { key, value in + UUID(uuidString: key).map { ($0, value.domainValue) } + }) + return value + } + } struct HostGroup: Codable { var base: VaultWireV1.HostGroup - var tailscale: TailscaleGroupConfiguration? + var tailscale: TailscaleConfiguration? enum CodingKeys: String, CodingKey { case tailscale } init(_ value: TermindDomain.HostGroup) { base = VaultWireV1.HostGroup(value) - tailscale = value.tailscale + tailscale = value.tailscale.map(TailscaleConfiguration.init) } init(from decoder: any Decoder) throws { base = try VaultWireV1.HostGroup(from: decoder) let values = try decoder.container(keyedBy: CodingKeys.self) - tailscale = try values.decode(TailscaleGroupConfiguration?.self, forKey: .tailscale) + tailscale = try values.decode(TailscaleConfiguration?.self, forKey: .tailscale) } func encode(to encoder: any Encoder) throws { @@ -28,7 +124,7 @@ nonisolated enum VaultWireV3 { var domainValue: TermindDomain.HostGroup { var value = base.domainValue - value.tailscale = tailscale + value.tailscale = tailscale?.domainValue return value } } diff --git a/Frameworks/TermindPersistence/VaultWireV4.swift b/Frameworks/TermindPersistence/VaultWireV4.swift new file mode 100644 index 00000000..1a18c294 --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWireV4.swift @@ -0,0 +1,102 @@ +import Foundation +import TermindDomain + +/// V4 adds backend-neutral session persistence without allowing older writers to erase it. +nonisolated enum VaultWireV4 { + struct Host: Codable { + var base: VaultWireV2.Host + var sessionPersistence: SessionPersistencePreferences + + init(_ value: TermindDomain.Host) throws { + var legacy = value + legacy.sessionPersistence = .init(enabled: false) + base = try VaultWireV2.Host(legacy) + sessionPersistence = value.sessionPersistence + } + + var domainValue: TermindDomain.Host { + var value = base.domainValue + value.sessionPersistence = sessionPersistence + return value + } + } + + struct HostGroup: Codable { + var base: VaultWireV3.HostGroup + var sessionPersistence: [String: SessionPersistencePreferences] + + init(_ value: TermindDomain.HostGroup) { + base = VaultWireV3.HostGroup(value) + sessionPersistence = Dictionary( + uniqueKeysWithValues: (value.tailscale?.hostPreferences ?? [:]).map { + ($0.key.uuidString.lowercased(), $0.value.sessionPersistence) + }) + } + + private enum CodingKeys: String, CodingKey { case base, sessionPersistence } + + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + base = try values.decode(VaultWireV3.HostGroup.self, forKey: .base) + sessionPersistence = try values.decode( + [String: SessionPersistencePreferences].self, forKey: .sessionPersistence) + guard + sessionPersistence.keys.allSatisfy({ + UUID(uuidString: $0)?.uuidString.lowercased() == $0 + && base.tailscale?.hostPreferences[$0] != nil + }) + else { + throw DecodingError.dataCorruptedError( + forKey: .sessionPersistence, in: values, debugDescription: "Invalid host identifier") + } + } + + var domainValue: TermindDomain.HostGroup { + var value = base.domainValue + for (key, preferences) in sessionPersistence { + if let id = UUID(uuidString: key), value.tailscale?.hostPreferences[id] != nil { + value.tailscale?.hostPreferences[id]?.sessionPersistence = preferences + } + } + 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 = 4 + key = envelope.key + version = envelope.version + isDeleted = envelope.isDeleted + switch envelope.payload { + case .host(let host): payload = .host(try Host(host)) + case .hostGroup(let group): payload = .hostGroup(HostGroup(group)) + case nil: payload = nil + default: throw CocoaError(.coderInvalidValue) + } + } + + var domainValue: VaultEnvelope { + let value: VaultRecordPayload? + switch payload { + case .host(let host): value = .host(host.domainValue) + case .hostGroup(let group): value = .hostGroup(group.domainValue) + case nil: value = nil + } + return VaultEnvelope( + key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion) + } + } +} diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 1a7112a6..b6f5437b 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -57,7 +57,8 @@ public struct ConnectionJourneyHeader: View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.title3.weight(.semibold)) - .lineLimit(1) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .help(title) Text(detail) .font(.callout.monospaced()) diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index b517e8b4..e6d284f1 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -1,6 +1,35 @@ import SwiftUI import TermindDomain +public struct HostSessionPersistenceSection: View { + @Binding private var preferences: SessionPersistencePreferences + + public init(preferences: Binding) { _preferences = preferences } + + public var body: some View { + Section(AppStrings.SessionPersistence.section.swiftUIKey) { + Toggle(isOn: $preferences.enabled) { Text(AppStrings.Common.enabled) } + Picker(AppStrings.SessionPersistence.backend.swiftUIKey, selection: $preferences.backend) { + Text(verbatim: "tmux").tag(SessionPersistenceBackendID.tmux) + if preferences.backend != .tmux { + Text(verbatim: preferences.backend.rawValue).tag(preferences.backend) + } + } + .pickerStyle(.menu) + .menuOrder(.fixed) + .disabled(!preferences.enabled) + Picker(AppStrings.SessionPersistence.onConnect.swiftUIKey, selection: $preferences.onConnect) { + Text(AppStrings.SessionPersistence.ask).tag(SessionPersistencePreferences.OnConnect.ask) + Text(AppStrings.SessionPersistence.create).tag(SessionPersistencePreferences.OnConnect.createManaged) + Text(AppStrings.SessionPersistence.normalShell).tag(SessionPersistencePreferences.OnConnect.plainShell) + } + .pickerStyle(.menu) + .menuOrder(.fixed) + .disabled(!preferences.enabled) + } + } +} + public struct HostIntegrationSection: View { @Binding private var mode: HostShellIntegrationMode @@ -23,6 +52,7 @@ public struct HostSessionSection: View { @Binding private var charset: RemoteCharset @Binding private var sftpInitialPath: String @Binding private var agentForwardingEnabled: Bool + @Binding private var sessionPersistence: SessionPersistencePreferences @Binding private var startupSnippetID: Snippet.ID? @Binding private var environmentVariables: [HostEnvironmentVariable] private let allowsAgentForwarding: Bool @@ -35,6 +65,7 @@ public struct HostSessionSection: View { sftpInitialPath: Binding, startupSnippetID: Binding = .constant(nil), agentForwardingEnabled: Binding = .constant(false), + sessionPersistence: Binding = .constant(.init()), allowsAgentForwarding: Bool = true, environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]), snippets: [Snippet] = [], @@ -46,6 +77,7 @@ public struct HostSessionSection: View { _startupSnippetID = startupSnippetID self.allowsAgentForwarding = allowsAgentForwarding _agentForwardingEnabled = agentForwardingEnabled + _sessionPersistence = sessionPersistence _environmentVariables = environmentVariables self.snippets = snippets self.snippetPackagePath = snippetPackagePath @@ -89,6 +121,8 @@ public struct HostSessionSection: View { } } + HostSessionPersistenceSection(preferences: $sessionPersistence) + Section("Environment Variables") { ForEach(environmentVariables.indices, id: \.self) { index in environmentVariableRow(at: index) diff --git a/Frameworks/TermindPresentation/RemoteSessionPromptView.swift b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift new file mode 100644 index 00000000..fd4da5f9 --- /dev/null +++ b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift @@ -0,0 +1,155 @@ +import SwiftUI +import TermindApplication +import TermindDomain + +public struct RemoteSessionPromptView: View { + private let coordinator: RemoteSessionCoordinator + private let cancel: () -> Void + @Environment(\.locale) private var locale + + public init(coordinator: RemoteSessionCoordinator, cancel: @escaping () -> Void) { + self.coordinator = coordinator + self.cancel = cancel + } + + public var body: some View { + if let prompt = coordinator.prompt { + VStack(alignment: .leading, spacing: 16) { + Text(AppStrings.SessionPersistence.title) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + if let failure = prompt.failure { + Text(message(for: failure)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + if prompt.sessions.isEmpty { + Text(AppStrings.SessionPersistence.empty).foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(prompt.sessions) { session in + Button { + coordinator.choose(.attach(session), for: prompt.id) + } label: { + VStack(alignment: .leading) { + Text(verbatim: session.name) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + if let windows = session.windows, let clients = session.clients { + Text( + verbatim: + AppStrings.SessionPersistence.details( + windows: windows, clients: clients, locale: locale) + ) + .font(.caption).foregroundStyle(.secondary) + } else if let windows = session.windows { + Text(AppStrings.SessionPersistence.windows(windows)) + .font(.caption).foregroundStyle(.secondary) + } else if let clients = session.clients { + Text(AppStrings.SessionPersistence.clients(clients)) + .font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + } + } + }.frame(maxHeight: 280) + } + } + Divider() + ViewThatFits(in: .horizontal) { + HStack { + cancelAction(for: prompt) + Spacer() + HStack { + skipAction(for: prompt) + primaryAction(for: prompt) + } + .fixedSize(horizontal: true, vertical: false) + } + VStack(alignment: .leading, spacing: 12) { + cancelAction(for: prompt) + skipAction(for: prompt) + primaryAction(for: prompt) + } + } + #if os(iOS) + .controlSize(.large) + #endif + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func cancelAction(for prompt: RemoteSessionCoordinator.Prompt) -> some View { + Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { + coordinator.cancelSelection(for: prompt.id) { cancel() } + } + .keyboardShortcut(.cancelAction) + } + + private func skipAction(for prompt: RemoteSessionCoordinator.Prompt) -> some View { + Button(AppStrings.Common.skip.swiftUIKey) { + coordinator.choose(.plainShell, for: prompt.id) + } + } + + private func primaryAction(for prompt: RemoteSessionCoordinator.Prompt) -> some View { + Button(prompt.failure == nil ? AppStrings.Common.new.swiftUIKey : AppStrings.Common.retry.swiftUIKey) { + coordinator.choose(prompt.failure == nil ? .create : .retry, for: prompt.id) + } + .buttonStyle(.borderedProminent) + .fixedSize(horizontal: true, vertical: false) + } + + private func message(for failure: RemoteSessionFailure) -> LocalizedStringResource { + switch failure { + case .missing: AppStrings.SessionPersistence.missing + case .unsupported: AppStrings.SessionPersistence.unsupported + case .timedOut: AppStrings.SessionPersistence.timedOut + case .sessionMissing, .ownershipMismatch: AppStrings.SessionPersistence.changed + case .invalidResponse, .commandFailed: AppStrings.SessionPersistence.failed + } + } +} + +public struct RemoteSessionClosePromptView: View { + private let coordinator: RemoteSessionCloseCoordinator + + public init(coordinator: RemoteSessionCloseCoordinator) { self.coordinator = coordinator } + + public var body: some View { + if let prompt = coordinator.prompt { + VStack(alignment: .leading, spacing: 16) { + Text(AppStrings.SessionPersistence.closeTitle).font(.headline) + Text(AppStrings.SessionPersistence.closeWarning) + ScrollView { + Text(verbatim: prompt.names.joined(separator: "\n")).font(.body.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 180) + if prompt.failure != nil { + Text(AppStrings.SessionPersistence.closeFailed).foregroundStyle(.red) + Button(AppStrings.SessionPersistence.disconnectOnly.swiftUIKey) { + coordinator.choose(.disconnectOnly, for: prompt.id) + } + } + Button(AppStrings.SessionPersistence.end.swiftUIKey, role: .destructive) { + coordinator.choose(.endSessions, for: prompt.id) + } + Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { + coordinator.choose(.cancel, for: prompt.id) + } + .keyboardShortcut(.defaultAction) + } + .padding(24) + .frame(maxWidth: 440, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.25)) + } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Connections.swift b/Frameworks/TermindPresentation/Strings/Connections.swift index cbf832f7..cf24ecdc 100644 --- a/Frameworks/TermindPresentation/Strings/Connections.swift +++ b/Frameworks/TermindPresentation/Strings/Connections.swift @@ -10,8 +10,8 @@ extension AppStrings { "; " } - public static var authenticationCancelled: LocalizedStringResource { - "Authentication was cancelled. Retry to continue connecting." + public static var connectionCancelled: LocalizedStringResource { + "Connection was cancelled. Retry to connect." } public static var agentForwarding: LocalizedStringResource { "Agent Forwarding" } diff --git a/Frameworks/TermindPresentation/Strings/Presentation.swift b/Frameworks/TermindPresentation/Strings/Presentation.swift index 3ec47ae4..23ad3186 100644 --- a/Frameworks/TermindPresentation/Strings/Presentation.swift +++ b/Frameworks/TermindPresentation/Strings/Presentation.swift @@ -1,6 +1,14 @@ import Foundation import TermindApplication +extension AppStrings.SessionPersistence { + public static func details(windows: Int, clients: Int, locale: Locale) -> String { + let windows = AppLocalization.resolve(Self.windows(windows), locale: locale) + let clients = AppLocalization.resolve(Self.clients(clients), locale: locale) + return AppLocalization.resolve(counts(windows: windows, clients: clients), locale: locale) + } +} + extension AppStrings.Vault { public static func groupDeletionMessage( hostCount: Int, nestedGroupCount: Int, locale: Locale diff --git a/Frameworks/TermindPresentation/Strings/SessionPersistence.swift b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift new file mode 100644 index 00000000..b2735568 --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift @@ -0,0 +1,42 @@ +import Foundation + +extension AppStrings { + public nonisolated enum SessionPersistence { + public static var section: LocalizedStringResource { "Session Persistence" } + public static var backend: LocalizedStringResource { "Backend" } + public static var onConnect: LocalizedStringResource { "On Connect" } + public static var ask: LocalizedStringResource { "Ask Every Time" } + public static var normalShell: LocalizedStringResource { "Use a Normal Shell" } + public static var title: LocalizedStringResource { "Choose a Remote Session" } + public static var create: LocalizedStringResource { "New Managed Session" } + public static var empty: LocalizedStringResource { "No remote sessions are running." } + public static var missing: LocalizedStringResource { "The session backend is not installed on this host." } + public static var unsupported: LocalizedStringResource { + "This session backend or reference version is not supported." + } + public static var timedOut: LocalizedStringResource { "Preparing the remote session timed out." } + public static var failed: LocalizedStringResource { "Unable to prepare the remote session." } + public static var closeFailed: LocalizedStringResource { + "Unable to end the remote session. Retry, or disconnect without ending it." + } + public static var changed: LocalizedStringResource { + "The saved remote session is missing or its identity changed." + } + public static var closeTitle: LocalizedStringResource { "End Managed Remote Sessions?" } + public static var closeWarning: LocalizedStringResource { + "Ending these sessions stops their remote processes for all clients. Cancel keeps them running." + } + public static var end: LocalizedStringResource { "End Sessions and Close" } + public static var disconnectOnly: LocalizedStringResource { "Disconnect Only" } + public static var detached: LocalizedStringResource { + "Detached from the remote session. Reconnect to attach again." + } + public static var ended: LocalizedStringResource { "The remote session has ended." } + public static var reattached: LocalizedStringResource { "Reattached to the existing remote session." } + public static func counts(windows: String, clients: String) -> LocalizedStringResource { + "\(windows), \(clients)" + } + public static func windows(_ count: Int) -> LocalizedStringResource { "\(count) windows" } + public static func clients(_ count: Int) -> LocalizedStringResource { "\(count) connected clients" } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift new file mode 100644 index 00000000..e0ddedc5 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift @@ -0,0 +1,227 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// One writer per shell instance; readers see an atomic, bounded snapshot plus event tail. +nonisolated enum RemoteShellContextJournal { + static let requestReader = #""" + #!/bin/sh + dir=$1; expected=$2 + [ -f "$dir/request" ] && [ ! -L "$dir/request" ] || exit 1 + IFS='|' read -r request seq action value deadline original < "$dir/request" || exit 1 + rm -f "$dir/request" + [ "$action" = "$expected" ] || exit 1 + case "$deadline:$seq" in *[!0-9:]*|:) exit 1 ;; esac + [ "$(date +%s)" -le "$deadline" ] || exit 1 + IFS='|' read -r tag current ready cwd < "$dir/state" || exit 1 + [ "$seq" = "$current" ] && [ "$ready" = 1 ] || exit 1 + printf '%s|%s|%s\n' "$request" "$value" "$original" + """# + + static func draftWidget(shell: String, replacing: Bool) -> String { + let action = replacing ? "replace" : "read" + if shell == "fish" { + return """ + set -l response (command sh "$TERMIND_TMUX_HELPERS/request-reader" "$__termind_context_dir" \(action)); + test $status -eq 0; or return; + set -l fields (string split '|' -- "$response"); + if test "$fields[3]" != '-'; and test (commandline | string collect) != (string unescape --style=url -- "$fields[3]" | string collect); return; end; + \(replacing ? "commandline -r -- (string unescape --style=url -- \"$fields[2]\");" : "") + __termind_event draft (string escape --style=url -- (commandline)) "$fields[1]" + """ + } + let buffer = shell == "bash" ? "READLINE_LINE" : "BUFFER" + let cursor = shell == "bash" ? "READLINE_POINT" : "CURSOR" + return """ + local response request value original; + response=$(command sh "$TERMIND_TMUX_HELPERS/request-reader" "$__termind_context_dir" \(action)) || return; + IFS='|' read -r request value original <<< "$response"; + if [ "$original" != '-' ]; then + original=$(builtin printf '%b.' "$(printf '%s' "$original" | sed 's/%/\\\\x/g')"); + [ "$\(buffer)" = "${original%.}" ] || return; + fi; + \(replacing ? "value=$(builtin printf '%b.' \"$(printf '%s' \"$value\" | sed 's/%/\\\\x/g')\"); \(buffer)=${value%.}; \(cursor)=${#\(buffer)};" : "") + __termind_event draft "$(__termind_encode "$\(buffer)")" "$request" + """ + } + + static let writer = #""" + #!/bin/sh + set -f + dir=$1; kind=$2; value=${3-}; extra=${4-} + [ -d "$dir" ] && [ ! -L "$dir" ] || exit 1 + case "$kind" in start|finish|prompt|directory|draft) ;; *) exit 1 ;; esac + [ "${#value}" -le 8192 ] || value= + [ "${#extra}" -le 8192 ] || extra= + seq=0; ready=0; cwd= + if [ -f "$dir/state" ]; then + IFS='|' read -r tag seq ready cwd < "$dir/state" + fi + seq=$((seq + 1)) + case "$kind" in start) ready=0 ;; prompt) ready=1 ;; directory) cwd=$value ;; esac + printf 'R1|%s|%s|%s\n' "$seq" "$ready" "$cwd" > "$dir/state.tmp" + mv "$dir/state.tmp" "$dir/state" + { + cat "$dir/meta" + cat "$dir/state" + if [ -f "$dir/journal" ]; then sed '1,2d' "$dir/journal" | tail -n 31; fi + printf 'E1|%s|%s|%s|%s\n' "$seq" "$kind" "$value" "$extra" + } > "$dir/journal.tmp" + mv "$dir/journal.tmp" "$dir/journal" + if [ "${TERMIND_CONTEXT_OSC:-}" = 1 ]; then + printf '\033]777;TermindContext=%s;' "$TERMIND_CONTEXT_TOKEN" + cat "$dir/meta" + cat "$dir/state" + printf 'E1|%s|%s|%s|%s\n\007' "$seq" "$kind" "$value" "$extra" + fi + """# + + static func initialization(shell: String, token: String) -> String { + if shell == "fish" { + return """ + if set -q __termind_context_dir; return; end + set -gx TERMIND_CONTEXT_TOKEN '\(token)' + set -gx __termind_context_dir (command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") + test -n "$__termind_context_dir"; or return + set -l pane shell + if set -q TMUX_PANE; set pane "$TMUX_PANE"; end + set -l history_name fish + if set -q fish_history; set history_name "$fish_history"; end + set -l history_base "$HOME/.local/share" + if set -q XDG_DATA_HOME; set history_base "$XDG_DATA_HOME"; end + set -l history_path '' + if test -n "$history_name"; set history_path "$history_base/fish/$history_name"_history; end + printf 'M1|%s|%s|%s|fish|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \ + (command basename "$__termind_context_dir") (string escape --style=url -- "$HOME") "$fish_pid" \ + (string escape --style=url -- "$history_path") > "$__termind_context_dir/meta" + function __termind_event + command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" $argv + end + """ + } + return """ + [ -z "${__termind_context_dir:-}" ] || return + export TERMIND_CONTEXT_TOKEN='\(token)' + __termind_context_dir=$(command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") || return + export __termind_context_dir + __termind_event() { command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" "$@"; } + __termind_home=$(printf '%s' "$HOME" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g') + __termind_history=${HISTFILE:-} + case "$__termind_history" in ''|/*) ;; *) __termind_history="$PWD/$__termind_history" ;; esac + __termind_history=$(printf '%s' "$__termind_history" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g') + printf 'M1|%s|%s|%s|\(shell)|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "${TMUX_PANE:-shell}" \ + "${__termind_context_dir##*/}" "$__termind_home" "$$" "$__termind_history" > "$__termind_context_dir/meta" + """ + } +} + +nonisolated struct RemoteShellContextRecord { + let token: String + let pane: String + let instance: String + let shell: String + let process: String + let home: String? + let historyPath: String? + let directory: String? + let ready: Bool + let sequence: UInt64 + let entries: [(UInt64, TerminalContextEvent.Payload)] + + init(_ text: String, charset: RemoteCharset = .utf8) throws { + guard text.utf8.count <= 512 * 1024 else { throw TerminalRuntimeError.invalidResponse } + // PTYs may expand the record's line separators to CRLF; field data is percent encoded. + let lines = text.replacingOccurrences(of: "\r\n", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: true) + guard lines.count >= 2 else { throw TerminalRuntimeError.invalidResponse } + let meta = lines[0].split(separator: "|", omittingEmptySubsequences: false).map(String.init) + let state = lines[1].split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard (7...8).contains(meta.count), meta[0] == "M1", UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil, + meta[2] == "shell" || Self.validPane(meta[2]), + meta[3].hasPrefix("ctx."), + meta[3].utf8.allSatisfy({ + $0 == 46 || (48...57).contains($0) + || (65...90).contains($0) || (97...122).contains($0) + }), + ["bash", "zsh", "fish"].contains(meta[4]), + state.count == 4, state[0] == "R1", let sequence = UInt64(state[1]), + ["0", "1"].contains(state[2]) + else { throw TerminalRuntimeError.invalidResponse } + token = meta[1] + pane = meta[2] + instance = meta[3] + shell = meta[4] + process = meta[6] + self.sequence = sequence + ready = state[2] == "1" + func decode(_ value: String) throws -> String { + let bytes = Array(value.utf8) + var index = 0 + while index < bytes.count { + if bytes[index] == 37 { + guard index + 2 < bytes.count, + bytes[(index + 1)...(index + 2)].allSatisfy({ + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + }) + else { throw TerminalRuntimeError.invalidResponse } + index += 3 + } else { + guard bytes[index] >= 32, bytes[index] < 127 else { throw TerminalRuntimeError.invalidResponse } + index += 1 + } + } + return RemoteCommandEventFilter.decodePercentEncoded(Data(bytes), charset: charset) + } + let home = try decode(meta[5]) + self.home = home.hasPrefix("/") ? home : nil + let history = try meta.count == 8 ? decode(meta[7]) : "" + historyPath = + (history.isEmpty || history.hasPrefix("/")) + && !history.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + ? history : nil + let cwd = try decode(state[3]) + directory = cwd.hasPrefix("/") ? cwd : nil + var events: [(UInt64, TerminalContextEvent.Payload)] = [] + var last: UInt64? + for line in lines.dropFirst(2) { + let parts = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 5, parts[0] == "E1", let seq = UInt64(parts[1]), seq <= sequence, + seq > 0, last == nil || (last! < UInt64.max && seq == last! + 1) + else { throw TerminalRuntimeError.invalidResponse } + last = seq + let event: TerminalContextEvent.Payload + switch parts[2] { + case "start": event = .commandStarted(try decode(parts[3])) + case "finish": + guard let status = Int32(parts[3]), (0...255).contains(status), + let ms = UInt64(parts[4]), ms <= 31_536_000_000 + else { throw TerminalRuntimeError.invalidResponse } + event = .commandFinished(status, duration: Double(ms) / 1000) + case "prompt": event = .promptReady + case "directory": event = .directory(try decode(parts[3])) + case "draft": + guard UUID(uuidString: parts[4]) != nil else { throw TerminalRuntimeError.invalidResponse } + event = .draft(request: parts[4], value: try decode(parts[3])) + default: throw TerminalRuntimeError.invalidResponse + } + events.append((seq, event)) + } + guard events.count <= 32, last == sequence else { throw TerminalRuntimeError.invalidResponse } + entries = events + } + + static func validPane(_ value: String) -> Bool { + value.hasPrefix("%") && value.count > 1 && value.dropFirst().utf8.allSatisfy { (48...57).contains($0) } + } + + func context(server: String, session: String, backend: String) -> TerminalShellContext { + TerminalShellContext( + identity: .init(backend: backend, server: server, session: session, pane: pane, shellInstance: instance), + shell: shell, directory: directory, home: home, promptReady: ready, sequence: sequence, + capabilities: [.directory, .commandLifecycle], historyPath: historyPath) + } + + func events(for context: TerminalShellContext) -> [TerminalContextEvent] { + entries.map { .init(context: context.identity, sequence: $0.0, payload: $0.1) } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift new file mode 100644 index 00000000..4767f522 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift @@ -0,0 +1,83 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated struct RemoteTerminalContextFilter { + struct Update { + var output = Data() + var terminal: String? + var contexts: [TerminalRuntimeUpdate] = [] + } + private let token: String + private let charset: RemoteCharset + private let prefixes: [Data] + private var buffer = Data() + + init(token: String, charset: RemoteCharset = .utf8) { + self.token = token + self.charset = charset + prefixes = [ + Data("\u{1B}]777;TermindTerminal=\(token);".utf8), + Data("\u{1B}]777;TermindContext=\(token);".utf8), + ] + } + + mutating func receive(_ data: Data) -> Update { + buffer.append(data) + var update = Update() + while let match = prefixes.enumerated().compactMap({ index, prefix in + buffer.range(of: prefix).map { (index, $0) } + }).min(by: { $0.1.lowerBound < $1.1.lowerBound }) { + update.output.append(buffer[.. 64 * 1024 { + buffer.removeAll() + update.contexts.append(.init(status: .unavailable)) + } + return update + } + let payloadBytes = Data(buffer[match.1.upperBound..= 32 && $0 < 127 }) { + update.terminal = payload + } + } else if let record = try? RemoteShellContextRecord(payload, charset: charset), record.token == token, + record.pane == "shell" + { + var context = record.context(server: token, session: token, backend: "shell") + if context.promptReady { + context.capabilities.formUnion([.readDraft, .replaceDraft]) + } else { + context.capabilities.remove(.directory) + } + update.contexts.append( + .init( + status: context.promptReady ? .ordinary : .unknown, + active: context.promptReady ? context.identity : nil, + contexts: [context], events: record.events(for: context))) + } else { + update.contexts.append(.init(status: .unavailable)) + } + } + let maximum = min(buffer.count, prefixes.map(\.count).max()! - 1) + var retained = 0 + if maximum > 0 { + for count in stride(from: maximum, through: 1, by: -1) { + if prefixes.contains(where: { buffer.suffix(count).elementsEqual($0.prefix(count)) }) { + retained = count + break + } + } + } + let end = buffer.index(buffer.endIndex, offsetBy: -retained) + update.output.append(buffer[.. String { + let identity = context.identity + guard context.promptReady, ["bash", "zsh", "fish"].contains(context.shell), + identity.shellInstance.hasPrefix("ctx."), + identity.shellInstance.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 46 + }), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true + else { throw TerminalRuntimeError.staleContext } + let root: String + let trigger: String + let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~" + if identity.backend == "tmux" { + let session = identity.session.split(separator: ":").map(String.init) + let client = clientIdentity?.split(separator: ":").map(String.init) ?? [] + guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil, + UInt64(session[1]) != nil, UInt64(identity.server) != nil, + RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal, + UInt64(client[1]) != nil, UInt64(client[2]) != nil + else { throw TerminalRuntimeError.staleContext } + let target = try RemoteTmuxCommands.quote(session[0]) + root = """ + [ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79 + [ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \ + awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \ + = '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79 + helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79 + helper=${helper#TERMIND_TMUX_HELPERS=} + """ + let guardFormat = + "#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}" + trigger = """ + tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \ + \(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79 + """ + } else { + guard identity.backend == "shell", let helper else { throw TerminalRuntimeError.staleContext } + root = "helper=\(try RemoteTmuxCommands.quote(helper))" + // The worker sends the negotiated widget key after this request is installed. + trigger = "printf 'READY\\n'" + } + let bytes = try RemoteCharsetStreamConverter.encode(replacement ?? "", charset: charset) + guard bytes.count <= 4096 else { throw TerminalRuntimeError.staleContext } + let encoded = bytes.map { String(format: "%%%02X", $0) }.joined() + let baseline = + try original.map { + try RemoteCharsetStreamConverter.encode($0, charset: charset).map { String(format: "%%%02X", $0) } + .joined() + } ?? "-" + return """ + umask 077 + \(root) + case "$helper" in /tmp/termind-shell.*) ;; *) exit 79 ;; esac + suffix=${helper#/tmp/termind-shell.} + case "$suffix" in ''|*[!a-zA-Z0-9]*) exit 79 ;; esac + dir="$helper/\(identity.shellInstance)" + [ -d "$dir" ] && [ ! -L "$dir" ] || exit 79 + IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79 + [ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79 + printf '%s|%s|%s|%s|%s|%s\\n' '\(request.uuidString)' '\(context.sequence)' \ + '\(replacement == nil ? "read" : "replace")' '\(encoded)' "$(( $(date +%s) + 2 ))" '\(baseline)' > "$dir/request.tmp" + mv "$dir/request.tmp" "$dir/request" + \(trigger) + """ + } + + static func resultCommand(context: TerminalShellContext, helper: String?, request: UUID) throws -> String { + let root: String + if context.identity.backend == "tmux" { + guard let session = context.identity.session.split(separator: ":").first else { + throw TerminalRuntimeError.staleContext + } + root = """ + helper=$(tmux show-environment -t \(try RemoteTmuxCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79 + helper=${helper#TERMIND_TMUX_HELPERS=} + """ + } else { + guard let helper else { throw TerminalRuntimeError.staleContext } + root = "helper=\(try RemoteTmuxCommands.quote(helper))" + } + return """ + \(root) + case "$helper" in /tmp/termind-shell.*) ;; *) exit 79 ;; esac + suffix=${helper#/tmp/termind-shell.} + case "$suffix" in ''|*[!a-zA-Z0-9]*) exit 79 ;; esac + file="$helper/\(context.identity.shellInstance)/journal" + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if [ -f "$file" ] && grep -F '|draft|' "$file" | grep -q -F '|\(request.uuidString)'; then cat "$file"; exit 0; fi + sleep 0.1 + done + exit 79 + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift new file mode 100644 index 00000000..e05cac7e --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift @@ -0,0 +1,143 @@ +import Foundation +import TermindApplication +import TermindDomain + +public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter { + private let execute: RemoteSessionCommandExecutor + private let charset: RemoteCharset + public init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) { + self.execute = execute + self.charset = charset + } + + public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { + let result = try await execute(Self.command(terminal: terminal)) + guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } + return try Self.parse(result.stdout, terminal: terminal, charset: charset) + } + + static func command(terminal: String) throws -> String { + guard terminal.hasPrefix("/dev/"), + terminal.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + || [47, 46, 95, 45].contains($0) + }) + else { throw TerminalRuntimeError.invalidResponse } + return """ + export LC_ALL=C + command -v tmux >/dev/null 2>&1 || exit 77 + tmux -V || exit 77 + client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \ + while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do + if [ "$tty" = \(try RemoteTmuxCommands.quote(terminal)) ]; then + printf 'C|%s|%s|%s|%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc" "$pane" "$flags" "$table" + fi + done) + if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi + printf '%s\\n' "$client" + sid=$(printf '%s' "$client" | cut -d '|' -f6) + tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}' || exit 78 + helper=$(tmux show-environment -t "$sid" TERMIND_TMUX_HELPERS 2>/dev/null) + helper=${helper#TERMIND_TMUX_HELPERS=} + case "$helper" in /tmp/termind-shell.*) ;; *) printf 'DONE\\n'; exit 0 ;; esac + suffix=${helper#/tmp/termind-shell.} + case "$suffix" in ''|*[!a-zA-Z0-9]*) printf 'DONE\\n'; exit 0 ;; esac + count=0 + for file in "$helper"/ctx.*/journal; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + count=$((count + 1)); [ "$count" -le 128 ] || exit 78 + printf 'BEGIN\\n' + cat "$file" || exit 78 + printf 'END\\n' + done + printf 'DONE\\n' + """ + } + + static func parse(_ text: String, terminal: String, charset: RemoteCharset = .utf8) throws -> TerminalRuntimeUpdate + { + var lines = text.components(separatedBy: "\n") + guard !lines.isEmpty else { throw TerminalRuntimeError.invalidResponse } + try RemoteTmuxCommands.validateVersion(lines.removeFirst()) + if lines == ["NONE", ""] { return .init(status: .unknown) } + guard let first = lines.first else { throw TerminalRuntimeError.invalidResponse } + let client = first.components(separatedBy: "|") + guard client.count == 10, client[0] == "C", client[1] == terminal, + [2, 3, 4, 6].allSatisfy({ UInt64(client[$0]) != nil }), + client[5].hasPrefix("$"), UInt64(client[5].dropFirst()) != nil, + RemoteShellContextRecord.validPane(client[7]) + else { throw TerminalRuntimeError.invalidResponse } + let flags = Set(client[8].split(separator: ",").map(String.init)) + guard !flags.contains("active-pane"), !flags.contains("read-only") else { + return .init(status: .unavailable) + } + let server = client[4] + let session = client[5] + ":" + client[6] + let clientIdentity = terminal + ":" + client[2] + ":" + client[3] + var panes: [String: (process: String, interactive: Bool, foregroundShell: String)] = [:] + var records: [RemoteShellContextRecord] = [] + var index = 1 + while index < lines.count { + let line = lines[index] + index += 1 + if line == "DONE" { + guard index == lines.count - 1, lines.last == "" else { + throw TerminalRuntimeError.invalidResponse + } + break + } + if line == "BEGIN" { + let start = index + while index < lines.count, lines[index] != "END" { index += 1 } + guard index < lines.count else { throw TerminalRuntimeError.invalidResponse } + records.append( + try RemoteShellContextRecord(lines[start.. Bool { + guard reference.version == 1, reference.backend == id, + reference.session.id == session.id, + let saved = try? TmuxSessionIdentity.decode(reference.session.identity), + let current = try? TmuxSessionIdentity.decode(session.identity), + saved.created == current.created, saved.serverPID == current.serverPID + else { return false } + return reference.ownershipToken.map { current.owner == $0.uuidString } ?? true + } + + public func probe() async throws -> RemoteSessionCapabilities { + let version = try await execute("command -v tmux >/dev/null 2>&1 || exit 77; tmux -V") + if version.exitCode == 77 { throw RemoteSessionFailure.missing } + try Self.check(version) + try RemoteTmuxCommands.validateVersion(version.stdout) + return capabilities + } + + public func sessions() async throws -> [RemoteSessionDescriptor] { + let result = try await execute(RemoteTmuxCommands.list) + try Self.check(result) + return try RemoteTmuxCommands.parseSessions(result.stdout) + } + + public func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] + ) async throws + -> RemoteSessionDescriptor + { + let result = try await execute( + RemoteTmuxCommands.create( + token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, + environment: environment)) + if result.exitCode == 80 { throw RemoteSessionFailure.unsupported } + try Self.check(result) + let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout) + guard confirmed.count == 1, let session = confirmed.first, + try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString + else { + throw RemoteSessionFailure.ownershipMismatch + } + return session + } + + public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch { + RemoteSessionLaunch( + command: try RemoteTmuxCommands.attach(attachment), attachment: attachment, created: created) + } + + public func terminate(_ attachment: RemoteSessionReference) async throws { + guard attachment.isManaged else { throw RemoteSessionFailure.ownershipMismatch } + let result = try await execute(RemoteTmuxCommands.terminate(attachment)) + if result.exitCode == 79 { throw RemoteSessionFailure.ownershipMismatch } + try Self.check(result) + guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch } + } + + private static func check(_ result: RemoteSessionCommandResult) throws { + guard !result.truncated else { throw RemoteSessionFailure.invalidResponse } + guard result.exitCode == 0 else { + throw RemoteSessionFailure.commandFailed(result.stderr) + } + } +} + +extension RemoteSessionBackendRegistry { + public static var live: Self { Self([.tmux: { RemoteTmuxService(execute: $0) }]) } +} + +private nonisolated struct TmuxSessionIdentity: Codable { + var version = 1 + let created: String + let serverPID: String + let owner: String? + + static func decode(_ data: Data) throws -> Self { + let value = try JSONDecoder().decode(Self.self, from: data) + guard value.version == 1 else { throw RemoteSessionFailure.unsupported } + return value + } +} + +public nonisolated enum RemoteTmuxCommands { + public static func quote(_ value: String) throws -> String { + guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + public static func validateVersion(_ output: String) throws { + let value = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.hasPrefix("tmux ") else { throw RemoteSessionFailure.invalidResponse } + let digits = value.dropFirst(5).prefix { $0.isNumber || $0 == "." }.split(separator: ".") + guard digits.count >= 2, let major = Int(digits[0]), let minor = Int(digits[1]), + major > 3 || (major == 3 && minor >= 3) + else { throw RemoteSessionFailure.unsupported } + } + + // Names and ownership values are hex-encoded, never interpolated into shell syntax or split on whitespace. + private static let describe = #""" + describe() { + id=$1 + fields=$(tmux display-message -p -t "$id" '#{session_id} #{session_created} #{pid} #{session_windows} #{session_attached}') || return + name=$(tmux display-message -p -t "$id" '#{session_name}' | od -An -v -tx1 | tr -d ' \n') + owner=$(tmux show-environment -t "$id" TERMIND_TMUX_OWNER 2>/dev/null | od -An -v -tx1 | tr -d ' \n') + printf 'S %s %s %s\n' "$fields" "$name" "${owner:--}" + } + """# + + public static var list: String { + """ + export LC_ALL=C + \(describe) + ids=$(tmux list-sessions -F '#{session_id}' 2>&1) + code=$? + if [ "$code" -ne 0 ]; then + case "$ids" in + "no server running on "*|*" (No such file or directory)") printf 'TERMIND_TMUX_1\\n'; exit 0 ;; + *) printf '%s\\n' "$ids" >&2; exit "$code" ;; + esac + fi + printf 'TERMIND_TMUX_1\\n' + for id in $ids; do describe "$id" || exit 78; done + """ + } + + public static func parseSessions(_ text: String) throws -> [RemoteSessionDescriptor] { + var lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.first == "TERMIND_TMUX_1", lines.last == "" else { throw RemoteSessionFailure.invalidResponse } + lines.removeFirst() + lines.removeLast() + var seen: Set = [] + return try lines.map { line in + let fields = line.split(separator: " ", omittingEmptySubsequences: false).map(String.init) + guard fields.count == 8, fields[0] == "S", validID(fields[1]), + seen.insert(fields[1]).inserted, + UInt64(fields[2]) != nil, UInt64(fields[3]) != nil, + let windows = Int(fields[4]), windows > 0, let clients = Int(fields[5]), clients >= 0, + let name = decodeLine(fields[6]) + else { throw RemoteSessionFailure.invalidResponse } + let owner: String? + if fields[7] == "-" { + owner = nil + } else { + guard let value = decodeLine(fields[7]), value.hasPrefix("TERMIND_TMUX_OWNER=") else { + throw RemoteSessionFailure.invalidResponse + } + owner = String(value.dropFirst("TERMIND_TMUX_OWNER=".count)) + } + return RemoteSessionDescriptor( + id: fields[1], name: name, + identity: try JSONEncoder().encode( + TmuxSessionIdentity(created: fields[2], serverPID: fields[3], owner: owner)), + windows: windows, clients: clients) + } + } + + private static func decodeLine(_ hex: String) -> String? { + guard hex.count.isMultiple(of: 2) else { return nil } + var bytes: [UInt8] = [] + var cursor = hex.startIndex + while cursor < hex.endIndex { + let end = hex.index(cursor, offsetBy: 2) + guard let byte = UInt8(hex[cursor.. Bool { + id.first == "$" && !id.dropFirst().isEmpty && id.dropFirst().utf8.allSatisfy { (48...57).contains($0) } + } + + static func managedSessionName(token: UUID) -> String { + // Keep 64 bits of a one-way digest: short to display, stable on retry, not an ownership credential. + "termind-" + + SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8) + .map { String(format: "%02x", $0) }.joined() + } + + public static func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] + ) throws -> String { + let owner = token.uuidString + let name = managedSessionName(token: token) + let wrapper = try RemoteTmuxShellStartup.install( + token: token, startupCommand: startupCommand, shellIntegration: shellIntegration) + guard environment.allSatisfy({ $0.isValid && !$0.variable.hasPrefix("TERMIND_") && $0.variable != "TMUX" }) + else { + throw RemoteSessionFailure.invalidResponse + } + let variables = try environment.map { "-e \(try quote("\($0.variable)=\($0.value)"))" }.joined(separator: " ") + let shell = + try environment.last(where: { $0.variable == "SHELL" }).map { try quote($0.value) } + ?? "\"${SHELL:-/bin/sh}\"" + let checkShell = + startupCommand == nil + ? "" + : """ + case \(shell) in */bash|*/zsh|*/fish) ;; *) exit 80 ;; esac + """ + return """ + \(describe) + session_shell=\(shell) + id=$(tmux list-sessions -f '#{==:#{session_name},\(name)}' -F '#{session_id}' 2>/dev/null) + if [ -n "$id" ]; then + [ "$(tmux show-environment -t "$id" TERMIND_TMUX_OWNER)" = 'TERMIND_TMUX_OWNER=\(owner)' ] || exit 79 + helper=$(tmux show-environment -t "$id" TERMIND_TMUX_HELPERS) || exit 78 + helper=${helper#TERMIND_TMUX_HELPERS=} + session_shell=$(tmux show-environment -t "$id" TERMIND_TMUX_SHELL) || exit 78 + session_shell=${session_shell#TERMIND_TMUX_SHELL=} + else + \(checkShell) + \(wrapper) + id=$(tmux new-session -d -P -F '#{session_id}' -s '\(name)' \(variables) -e 'TERMIND_TMUX_OWNER=\(owner)' \ + -e "TERMIND_TMUX_SHELL=$session_shell" -e "TERMIND_TMUX_HELPERS=$helper" "$helper/first" --) || exit $? + fi + tmux set-option -t "$id" default-shell "$helper/shell" || exit $? + tmux set-option -t "$id" default-command '' || exit $? + printf 'TERMIND_TMUX_1\\n' + describe "$id" + """ + } + + private static func identityCondition(_ attachment: RemoteSessionReference) throws -> String { + let session = attachment.session + let identity = try TmuxSessionIdentity.decode(session.identity) + guard attachment.version == 1, attachment.backend == .tmux else { throw RemoteSessionFailure.unsupported } + guard validID(session.id), UInt64(identity.created) != nil, UInt64(identity.serverPID) != nil else { + throw RemoteSessionFailure.invalidResponse + } + var condition = "#{&&:#{==:#{session_created},\(identity.created)},#{==:#{pid},\(identity.serverPID)}}" + if let token = attachment.ownershipToken { + condition = "#{&&:\(condition),#{==:#{TERMIND_TMUX_OWNER},\(token.uuidString)}}" + } + return condition + } + + public static func attach(_ attachment: RemoteSessionReference) throws -> String { + let target = try quote(attachment.session.id) + // The guard and attach share tmux's command queue; never use -d, -A, or a name prefix. + let command = "attach-session -t \(target)" + return + "exec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'" + } + + public static func terminate(_ attachment: RemoteSessionReference) throws -> String { + guard attachment.ownershipToken != nil else { throw RemoteSessionFailure.ownershipMismatch } + let target = try quote(attachment.session.id) + return """ + [ "$(tmux display-message -p -t \(target) \(try quote(identityCondition(attachment))))" = 1 ] || exit 79 + tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \ + \(try quote("kill-session -t \(target) ; display-message -p TERMIND_ENDED")) + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift new file mode 100644 index 00000000..098cb3cf --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Private, per-session startup files. Nothing is ever typed into an attached terminal. +nonisolated enum RemoteTmuxShellStartup { + static func install( + token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false + ) throws -> String { + let eventToken = ordinary ? token : UUID() + let bootstrap = RemoteShellIntegrationBootstrap(token: eventToken.uuidString) + let startup = try RemoteTmuxCommands.quote(startupCommand ?? "") + let reportsStartup = shellIntegration && startupCommand?.isEmpty == false + let posixStartup = """ + if [ "${TERMIND_TMUX_FIRST:-}" = 1 ]; then + unset TERMIND_TMUX_FIRST + \(reportsStartup ? "__termind_startup_time=$(date +%s); __termind_event start \"$(__termind_encode \(startup))\"" : "") + eval \(startup) + \(reportsStartup ? "__termind_startup_status=$?; __termind_event finish \"$__termind_startup_status\" \"$(( ($(date +%s) - __termind_startup_time) * 1000 ))\"" : "") + fi + """ + let fishStartup = """ + if test "$TERMIND_TMUX_FIRST" = 1 + set -e TERMIND_TMUX_FIRST + \(reportsStartup ? "set -g __termind_startup_time (date +%s); __termind_event start (string escape --style=url -- \(startup))" : "") + eval \(startup) + \(reportsStartup ? "set -l command_status $status; set -l elapsed (math (date +%s) - $__termind_startup_time); __termind_event finish $command_status (math $elapsed '*' 1000)" : "") + end + """ + let bash = + (shellIntegration + ? RemoteShellContextJournal.initialization(shell: "bash", token: eventToken.uuidString) + + "\n" + String(decoding: bootstrap.installationCommand(for: "bash", runtime: true)!, as: UTF8.self) + : "") + + "\n" + (shellIntegration ? "__termind_command_ready=0\n" : "") + posixStartup + + (shellIntegration ? "\n__termind_report_prompt\n" : "") + let zsh = + (shellIntegration + ? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString) + + "\n" + String(decoding: bootstrap.installationCommand(for: "zsh", runtime: true)!, as: UTF8.self) + : "") + + "\n" + posixStartup + let fish = + (shellIntegration + ? RemoteShellContextJournal.initialization(shell: "fish", token: eventToken.uuidString) + + "\n" + String(decoding: bootstrap.installationCommand(for: "fish", runtime: true)!, as: UTF8.self) + : "") + + "\n" + fishStartup + let shell = """ + #!/bin/sh + SHELL=${TERMIND_TMUX_SHELL:-${SHELL:-/bin/sh}} + export SHELL + if [ "$#" -gt 0 ]; then exec "$SHELL" "$@"; fi + unset __termind_context_dir + export TERMIND_CONTEXT_OSC=\(ordinary ? "1" : "0") + case "$SHELL" in + */bash) + export PROMPT_COMMAND='. "$TERMIND_TMUX_HELPERS/bashrc"' + exec "$SHELL" --noprofile -il ;; + */zsh) export TERMIND_USER_ZDOTDIR="${ZDOTDIR:-$HOME}"; export ZDOTDIR="$TERMIND_TMUX_HELPERS"; exec "$SHELL" -il ;; + */fish) exec "$SHELL" -il --init-command "source \\"$TERMIND_TMUX_HELPERS/fish\\"" ;; + *) exec "${SHELL:-/bin/sh}" -l ;; + esac + """ + let bashrc = """ + [ -z "${__termind_context_dir:-}" ] || return + unset PROMPT_COMMAND + # Keep login-shell semantics while running the hooks after user configuration. + # An exec in a login script intentionally bypasses the rest of this wrapper. + [ ! -r /etc/profile ] || . /etc/profile + for profile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -r "$profile" ]; then . "$profile"; break; fi + done + \(bash) + """ + // Run the first command inside the initialized shell, preserving cd/export state. + // Subsequent ordinary panes use `shell` directly and cannot replay this command. + let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n" + var files = [ + "shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer, + "request-reader": RemoteShellContextJournal.requestReader, + ] + for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { + files[name] = """ + export ZDOTDIR="$TERMIND_USER_ZDOTDIR" + [[ -r "$TERMIND_USER_ZDOTDIR/\(name)" ]] && source "$TERMIND_USER_ZDOTDIR/\(name)" + export TERMIND_USER_ZDOTDIR="$ZDOTDIR" + export ZDOTDIR="$TERMIND_TMUX_HELPERS" + """ + } + files[".zlogin", default: ""] += "\nexport ZDOTDIR=\"$TERMIND_USER_ZDOTDIR\"\n\(zsh)\n" + let writes = try files.keys.sorted().map { name in + "printf '%s\\n' \(try RemoteTmuxCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78" + }.joined(separator: "\n") + return """ + umask 077 + helper=$(mktemp -d "/tmp/termind-shell.XXXXXXXX") || exit 78 + \(writes) + chmod 700 "$helper/shell" "$helper/first" || exit 78 + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMaxSessionsPolicy.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMaxSessionsPolicy.swift index bcff5edf..be5816a1 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMaxSessionsPolicy.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMaxSessionsPolicy.swift @@ -95,6 +95,17 @@ nonisolated final class SSHChannelBudget: @unchecked Sendable { init(maximum: Int) { self.maximum = maximum } + var permitsConcurrentControl: Bool { lock.withLock { !closed && maximum > 1 } } + + /// Runtime observations must not wait behind a long-lived terminal channel. + func tryAcquire() -> SSHChannelLease? { + lock.withLock { + guard !closed, active < maximum, waiters.isEmpty else { return nil } + active += 1 + return SSHChannelLease(budget: self) + } + } + func acquire(cancellation: TransferCancellationToken? = nil) async throws -> SSHChannelLease { if cancellation?.isCancelled == true { throw CancellationError() } let id = UUID() diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift index 641f79df..7b4dbbd8 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift @@ -107,7 +107,7 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { private var agentRequestOwner: UUID? private final class SSHExecChannel: @unchecked Sendable { - enum Phase { case opening, forwarding, starting, running, closing, freeing } + enum Phase { case opening, forwarding, starting, running, closing, waitingForClose, freeing } let id = UUID() let command: StableCString @@ -135,10 +135,13 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { var signal: String? var started = false var completed = false + let retriesChannelOpen: Bool + let validateContext: @Sendable () -> Bool + var nextOpenAttempt: ContinuousClock.Instant? init( command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset, - usesLoginShell: Bool, + usesLoginShell: Bool, retriesChannelOpen: Bool, validateContext: @escaping @Sendable () -> Bool, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease, @@ -154,6 +157,8 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { self.command = StableCString( try RemoteCharsetStreamConverter.encode(effective, charset: charset)) self.charset = charset + self.retriesChannelOpen = retriesChannelOpen + self.validateContext = validateContext stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) self.cancellation = cancellation @@ -174,7 +179,10 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { func execute( _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?, - usesLoginShell: Bool = true + usesLoginShell: Bool = true, + retriesChannelOpen: Bool = false, + requiresImmediateCapacity: Bool = false, + validateContext: @escaping @Sendable () -> Bool = { true } ) async throws -> SSHExecResult { guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw TermindSSHError.library("The command is empty.") @@ -188,13 +196,29 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { } logger.debug("ssh.exec.started", metadata: metadata) do { - guard let channelBudget = try await queue.perform({ self.context()?.channelBudget }) else { + guard + let channelBudget = try await queue.perform({ + guard validateContext() else { throw CancellationError() } + return self.context()?.channelBudget + }) + else { throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected) } - let channelLease = try await channelBudget.acquire(cancellation: cancellation) + let channelLease: SSHChannelLease + if requiresImmediateCapacity { + guard let lease = channelBudget.tryAcquire() else { throw TerminalRuntimeError.capacityUnavailable } + channelLease = lease + } else { + channelLease = try await channelBudget.acquire(cancellation: cancellation) + } let result = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in queue.async { [self] in + guard validateContext() else { + channelLease.release() + continuation.resume(throwing: CancellationError()) + return + } guard self.context() != nil, self.context()?.channelBudget === channelBudget else { channelLease.release() continuation.resume( @@ -205,7 +229,8 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { do { context = try SSHExecChannel( command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset, - usesLoginShell: usesLoginShell, + usesLoginShell: usesLoginShell, retriesChannelOpen: retriesChannelOpen, + validateContext: validateContext, timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput, channelLease: channelLease, continuation: continuation) } catch { @@ -245,6 +270,7 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { finish(command, throwing: cancellationError(for: command)!) return true } + if let next = command.nextOpenAttempt, ContinuousClock.now < next { return false } guard let execChannel = libssh2_channel_open_ex( session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0) @@ -254,6 +280,13 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { blocked(session) return false } + if code == LIBSSH2_ERROR_CHANNEL_FAILURE, command.retriesChannelOpen { + // OpenSSH may acknowledge CLOSE before reaping its child/session slot. + // Only retry OPEN: no exec request has been sent, so commands cannot replay. + command.nextOpenAttempt = ContinuousClock.now.advanced(by: .milliseconds(50)) + queue.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in self?.schedulePump() } + return false + } opening = nil let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to open SSH exec Channel") if isConnectionError(code) { @@ -347,6 +380,16 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { blocked(session) return false } + command.phase = result == 0 ? .waitingForClose : .freeing + ready.insert(command.id) + return true + case .waitingForClose: + // wait_closed requires EOF; cancellation may close a still-running channel. + let result = libssh2_channel_eof(execChannel) == 1 ? libssh2_channel_wait_closed(execChannel) : 0 + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } if command.terminalError == nil { command.exitCode = libssh2_channel_get_exit_status(execChannel) command.signal = exitSignal(execChannel: execChannel, session: session) @@ -499,7 +542,7 @@ nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { } private func cancellationError(for command: SSHExecChannel) -> (any Error)? { - if command.cancellation.isCancelled { return CancellationError() } + if command.cancellation.isCancelled || !command.validateContext() { return CancellationError() } if ContinuousClock.now >= command.deadline { return TermindSSHError.library("The remote command timed out.") } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 2b4b53bf..03068988 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -286,7 +286,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { return decoded } - private mutating func parseSystemInfo(in data: Data) -> RemoteSystemInfo? { + fileprivate mutating func parseSystemInfo(in data: Data) -> RemoteSystemInfo? { let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) @@ -441,11 +441,68 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { ?? data.startIndex } - private func installationCommand(for shell: String?) -> Data? { + func installationCommand(for shell: String?, runtime: Bool = false) -> Data? { + let cwdReport = + runtime + ? "__termind_event directory \"$(__termind_encode \"$PWD\")\"" + : "builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"" + let bashFinished = + runtime + ? "__termind_event finish \"$status\" \"$duration\"" + : "builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"" + let zshFinished = + runtime + ? "__termind_event finish \"$command_status\" \"$duration\"" + : "builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"" + let promptReport = runtime ? "__termind_event prompt" : "builtin printf '\\e]777;TermindPrompt=\(token)\\a'" + let bashStarted = + runtime + ? "__termind_event start \"$(__termind_encode \"$cmd\")\"" + : "builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"" + let zshStarted = + runtime + ? "__termind_event start \"$(__termind_encode \"$1\")\"" + : "builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"" + let bashDraft = + runtime + ? RemoteShellContextJournal.draftWidget(shell: "bash", replacing: false) + : "builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"" + let zshDraft = + runtime + ? RemoteShellContextJournal.draftWidget(shell: "zsh", replacing: false) + : "builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"" + let fishCwd = + runtime + ? "__termind_event directory (string escape --style=url -- $PWD)" + : "printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD)" + let fishPrompt = runtime ? "__termind_event prompt" : "printf '\\e]777;TermindPrompt=\(token)\\a'" + let fishStarted = + runtime + ? "__termind_event start (string escape --style=url -- $argv[1])" + : "printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1])" + let fishFinished = + runtime + ? "__termind_event finish $command_status $command_duration" + : "printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration" + let fishDraft = + runtime + ? RemoteShellContextJournal.draftWidget(shell: "fish", replacing: false) + : "printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"" + let bashClear = + runtime + ? RemoteShellContextJournal.draftWidget(shell: "bash", replacing: true) : "READLINE_LINE=; READLINE_POINT=0" + let zshClear = + runtime ? RemoteShellContextJournal.draftWidget(shell: "zsh", replacing: true) : "BUFFER=; CURSOR=0" + let fishClear = + runtime + ? RemoteShellContextJournal.draftWidget(shell: "fish", replacing: true) + : "commandline -r ''; commandline -C 0" let marker = "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'" let historyMarker = "__termind_history_path=\"${HISTFILE-}\"; if [[ -n \"$__termind_history_path\" && \"$__termind_history_path\" != /* ]]; then __termind_history_path=\"$PWD/$__termind_history_path\"; fi; builtin printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' \"$(__termind_encode \"$__termind_history_path\")\"; unset __termind_history_path; " + let fishMetadata = + "set -l __termind_hname fish; if set -q fish_history; set __termind_hname \"$fish_history\"; end; set -l __termind_hbase \"$HOME/.local/share\"; if set -q XDG_DATA_HOME; and test -n \"$XDG_DATA_HOME\"; set __termind_hbase \"$XDG_DATA_HOME\"; end; set -l __termind_hpath ''; if test -n \"$__termind_hname\"; set __termind_hpath \"$__termind_hbase/fish/$__termind_hname\"_history; end; printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' (string escape --style=url -- \"$__termind_hpath\"); set -e __termind_hname __termind_hbase __termind_hpath; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; " let command: String switch shell { case "bash": @@ -457,15 +514,15 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then", "__termind_integration_token=\"\(token)\";", "__termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; };", - "__termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; };", - "__termind_report_prompt() { builtin local status=$? duration=0; if (( $# > 0 )); then status=$1; fi; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; __termind_command_ready=1; return \"$status\"; };", + "__termind_report_cwd() { \(cwdReport); };", + "__termind_report_prompt() { builtin local status=$? duration=0; if (( $# > 0 )); then status=$1; fi; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; \(bashFinished); builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; \(promptReport); __termind_command_ready=1; return \"$status\"; };", "__termind_prompt_begin() { __termind_prompt_status=$?; __termind_command_ready=0; return \"$__termind_prompt_status\"; };", "__termind_prompt_end() { builtin local status=\"${__termind_prompt_status:-$?}\"; builtin unset __termind_prompt_status; __termind_report_prompt \"$status\"; };", - "__termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; case \"$cmd\" in __termind_*) return \"$status\";; esac; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; };", + "__termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; case \"$cmd\" in __termind_*) return \"$status\";; esac; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then \(bashStarted); __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; };", "__termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; };", - "__termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; };", - "__termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; };", - "for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done;", + "__termind_compose_draft() { \(bashDraft); };", + "__termind_compose_clear() { \(bashClear); };", + "for __termind_map in emacs-standard vi-insert vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done;", "builtin unset __termind_map;", "if [[ -n \"${bash_preexec_imported:-}\" ]]; then", "[[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command);", @@ -480,17 +537,17 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { "fi;", "fi;", "fi;", - "\(historyMarker)\(marker);", + runtime ? "" : "\(historyMarker)\(marker);", "__termind_report_prompt", ].joined(separator: " ") + "\n" case "zsh": // Unlike bash, zsh reserves `status` as a read-only special parameter. // Assigning it aborts this function and the remaining prompt hooks. command = - " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(historyMarker)\(marker); __termind_report_prompt\n" + " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { \(cwdReport); }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; \(zshFinished); unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; \(promptReport); return \"$command_status\"; }; function __termind_report_command { \(zshStarted); typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { \(zshDraft); zle redisplay; }; function __termind_compose_clear { \(zshClear); zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(runtime ? "" : historyMarker + marker + "; ")__termind_report_prompt\n" case "fish": command = - " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; set -l __termind_hname fish; if set -q fish_history; set __termind_hname \"$fish_history\"; end; set -l __termind_hbase \"$HOME/.local/share\"; if set -q XDG_DATA_HOME; and test -n \"$XDG_DATA_HOME\"; set __termind_hbase \"$XDG_DATA_HOME\"; end; set -l __termind_hpath ''; if test -n \"$__termind_hname\"; set __termind_hpath \"$__termind_hbase/fish/$__termind_hname\"_history; end; printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' (string escape --style=url -- \"$__termind_hpath\"); set -e __termind_hname __termind_hbase __termind_hpath; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" + " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; \(fishCwd); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; \(fishPrompt); end; function __termind_report_command --on-event fish_preexec; \(fishStarted); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; \(fishFinished); set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); \(fishDraft); end; function __termind_compose_clear; \(fishClear); end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; \(runtime ? "" : fishMetadata)__termind_report_prompt\n" default: return nil } @@ -648,17 +705,32 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { } } +private nonisolated final class SSHPTYStartupBytes: @unchecked Sendable { + let pointer: UnsafeMutablePointer + let count: UInt32 + init(_ data: Data) { + count = UInt32(data.count) + pointer = .allocate(capacity: data.count + 1) + for (index, byte) in data.enumerated() { pointer[index] = CChar(bitPattern: byte) } + pointer[data.count] = 0 + } + deinit { pointer.deallocate() } +} + public actor SSHPTYChannel { public typealias OutputHandler = @Sendable (Data) -> Void public typealias ExitHandler = @Sendable (Result) -> Void public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void + public typealias StartupPreparation = + @Sendable (@escaping RemoteSessionCommandExecutor) async throws -> SSHPTYStartupPlan? private nonisolated let worker: SSHPTYChannelWorker private let keyboardInteractive: SSHKeyboardInteractiveHandler? private let agentForwardingFactory: SSHAgentForwardingFactory? private var environmentVariables: [HostEnvironmentVariable] = [] private var environmentRejection: EnvironmentRejectionHandler = { _ in } + private var startupPreparation: StartupPreparation? public init( output: @escaping OutputHandler, exit: @escaping ExitHandler, @@ -689,6 +761,7 @@ public actor SSHPTYChannel { using: request, environmentVariables: environmentVariables, environmentRejection: environmentRejection, + startupPreparation: startupPreparation, shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, progress: progress ) @@ -702,6 +775,30 @@ public actor SSHPTYChannel { environmentRejection = rejection } + public func configureStartup(_ preparation: @escaping StartupPreparation) { + startupPreparation = preparation + } + + public func sessionCommandExecutor() async throws -> RemoteSessionCommandExecutor { + try await worker.sessionCommandExecutor() + } + + public func runtimeCommandExecutor() async throws -> RemoteSessionCommandExecutor { + try await worker.sessionCommandExecutor(runtime: true) + } + + public func contextDraft(_ context: TerminalShellContext, replacing command: String? = nil) async throws -> String { + try await worker.contextDraft(context, replacing: command) + } + + public func confirmContext(_ context: TerminalShellContext) async throws { + try await worker.confirmContext(context) + } + + public func releaseTerminalForControl() async throws { + try await worker.releaseTerminalForControl() + } + public func write(_ data: Data) { worker.write(data) } @@ -751,6 +848,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var connection: SSHAuthenticatedConnection? private var ptyChannel: OpaquePointer? + private var openingPTY: OpaquePointer? + private var retainsControlConnection = false private var hostID: Host.ID? private var charset: RemoteCharset = .utf8 private var pumpTimer: (any DispatchSourceTimer)? @@ -763,6 +862,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var shellIntegration: RemoteShellIntegrationBootstrap? private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger private var commandEventFilter: RemoteCommandEventFilter? + private var contextFilter: RemoteTerminalContextFilter? + private var runtimeTask: Task? + private var runtimeTerminal: String? + private var runtimeRevision: UInt64 = 0 + private var runtimeEnabled = false + private var runtimeWantsObservation = false + private var runtimeControlUnavailable = false + private var runtimeHelper: String? + private var runtimeInputRevision: UInt64 = 0 + private var latestRuntimeUpdate: TerminalRuntimeUpdate? + private var pendingRuntimeDraft: UUID? + private var runtimeDraftBaseline: (TerminalContextIdentity, String)? private var inputConverter: RemoteCharsetStreamConverter? private var outputConverter: RemoteCharsetStreamConverter? private var workingDirectoryFilter: RemoteOSC7CharsetFilter? @@ -805,7 +916,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { self.cleanup() }, didCloseLastChannel: { [weak self] in - guard let self, self.ptyChannel == nil, self.sftpChannelOwner.isEmptyOnQueue else { return } + guard let self, !self.retainsControlConnection, + self.ptyChannel == nil, self.sftpChannelOwner.isEmptyOnQueue + else { return } self.cleanup() }) sftpChannelOwner = SSHMultiplexedSFTPChannelOwner( @@ -823,7 +936,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { self?.updateSharedConnectionEvents(session: session, buffer: buffer) }, didCloseLastChannel: { [weak self] in - guard let self, self.ptyChannel == nil else { return } + guard let self, !self.retainsControlConnection, self.ptyChannel == nil else { return } self.cleanup() }) } @@ -1024,6 +1137,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { using request: SSHConnectionRequest, environmentVariables: [HostEnvironmentVariable], environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler, + startupPreparation: SSHPTYChannel.StartupPreparation? = nil, shellIntegrationEnabled: Bool, columns: UInt16, rows: UInt16, progress: SSHRouteProgressHandler? @@ -1045,6 +1159,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ) } do { + if startupPreparation != nil || shellIntegrationEnabled { + try await connectPrepared( + request: request, generation: generation, environment: encodedEnvironment, + environmentRejection: environmentRejection, shellIntegrationEnabled: shellIntegrationEnabled, + preparation: startupPreparation ?? { _ in nil }, progress: progress) + return + } let channelBudget = maxSessionsPolicy.makeBudget() let ptyChannelLease = try await channelBudget.acquire() let openedConnection = try await Self.openAuthenticatedConnection( @@ -1150,6 +1271,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public func write(_ data: Data) { guard !data.isEmpty else { return } queue.async { [self] in + runtimeRevision &+= 1 + runtimeInputRevision &+= 1 + runtimeDraftBaseline = nil + // Input expires in-flight operations, but does not itself prove a context switch. + // Keep the last observation until inspection replaces it; operations revalidate it. if var integration = shellIntegration { integration.noteUserInput() shellIntegration = integration @@ -1172,6 +1298,273 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public func disconnect() async { await queue.perform { self.closeTerminalSession() } } + func sessionCommandExecutor( + runtime: Bool = false, expectedInput: UInt64? = nil + ) async throws -> RemoteSessionCommandExecutor { + let generation = try await queue.perform { [self] in + guard connection != nil else { throw TermindSSHError.disconnected } + return ptyResize.generation + } + return { [self] command in + let cancellation = TransferCancellationToken() + let timeout: TimeInterval = runtime ? 2 : 15 + let deadline = ContinuousClock.now.advanced(by: .seconds(timeout)) + let timer = Task { + try await Task.sleep(until: deadline, clock: .continuous) + cancellation.cancel() + } + defer { timer.cancel() } + return try await withTaskCancellationHandler { + try await queue.perform { [self] in + guard connection != nil, ptyResize.generation == generation else { throw CancellationError() } + } + let result: SSHExecResult + do { + result = try await execChannelOwner.execute( + command, workingDirectory: nil, stdin: nil, timeout: timeout, outputLimit: 1024 * 1024, + cancellation: cancellation, onOutput: nil, usesLoginShell: false, retriesChannelOpen: !runtime, + requiresImmediateCapacity: runtime, + validateContext: { [self] in + ptyResize.generation == generation + && (expectedInput == nil || runtimeInputRevision == expectedInput) + }) + } catch { + try Task.checkCancellation() + if runtime, case SSHCommandExecutionError.channelUnavailable = error { + await queue.perform { [self] in + guard ptyResize.generation == generation else { return } + runtimeControlUnavailable = true + publishRuntime(latestRuntimeUpdate ?? .init(status: .unavailable)) + } + } + if ContinuousClock.now >= deadline { throw RemoteSessionFailure.timedOut } + throw error + } + try await queue.perform { [self] in + guard connection != nil, ptyResize.generation == generation else { throw CancellationError() } + } + return RemoteSessionCommandResult( + stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, + truncated: result.stdoutTruncated || result.stderrTruncated || result.signal != nil) + } onCancel: { + cancellation.cancel() + } + } + } + + private func connectPrepared( + request: SSHConnectionRequest, generation: UUID, environment: [(String, Data, Data)], + environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler, + shellIntegrationEnabled: Bool, preparation: SSHPTYChannel.StartupPreparation, + progress: SSHRouteProgressHandler? + ) async throws { + let opened = try await Self.openAuthenticatedConnection( + using: request, timeout: 20, progress: progress, logger: logger) + try await queue.perform { [self] in + guard ptyResize.generation == generation else { + opened.close() + throw CancellationError() + } + connection = opened + retainsControlConnection = true + hostID = request.configuration.hostID + charset = request.configuration.charset + channelBudget = maxSessionsPolicy.makeBudget() + buffer = opened.installBuffer() + if let provider = request.agentForwarding { + buffer?.agentChannels = SSHAgentChannels(provider: provider, queue: queue) { [weak self] in self?.pump() + } + libssh2_session_callback_set2( + opened.libssh2Session, LIBSSH2_CALLBACK_AUTHAGENT, + unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self)) + } + libssh2_session_set_blocking(opened.libssh2Session, 0) + libssh2_keepalive_config(opened.libssh2Session, 1, 20) + startPump() + } + let executor = try await sessionCommandExecutor() + let launch = try await preparation(executor) + try Task.checkCancellation() + if shellIntegrationEnabled { + var probe = RemoteShellIntegrationBootstrap(charset: charset) + if let result = try? await executor(String(decoding: probe.startCommand, as: UTF8.self)), + result.exitCode == 0, !result.truncated, + let info = probe.parseSystemInfo(in: Data(result.stdout.utf8)) + { + await queue.perform { [self] in + guard ptyResize.generation == generation else { return } + shellIntegrationHandler(.systemInfo(info)) + } + } + } + let runtimeToken = UUID() + var usesRuntime = shellIntegrationEnabled + var startupCommand = launch?.command + if launch == nil, shellIntegrationEnabled { + do { + let install = try RemoteTmuxShellStartup.install( + token: runtimeToken, startupCommand: nil, shellIntegration: true, ordinary: true) + let result = try await executor(install + "\nprintf '%s\\n' \"$helper\"\n") + guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } + let helper = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard helper.hasPrefix("/tmp/termind-shell."), + !helper.dropFirst("/tmp/termind-shell.".count).isEmpty, + helper.dropFirst("/tmp/termind-shell.".count).utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + }) + else { throw TerminalRuntimeError.invalidResponse } + startupCommand = + "export TERMIND_TMUX_HELPERS=\(try RemoteTmuxCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\"" + await queue.perform { self.runtimeHelper = helper } + } catch { + try Task.checkCancellation() + // Startup-only integration is optional; never fall back to typing a bootstrap. + usesRuntime = false + } + } + if let original = startupCommand, usesRuntime { + startupCommand = + "printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original + } + let command = try startupCommand.map { + SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteTmuxCommands.quote($0))).utf8)) + } + let runtimeIsEnabled = usesRuntime + try await queue.perform { [self] in + guard ptyResize.generation == generation else { throw CancellationError() } + runtimeEnabled = runtimeIsEnabled + runtimeWantsObservation = runtimeIsEnabled && launch != nil + if runtimeIsEnabled { + contextFilter = .init(token: runtimeToken.uuidString, charset: charset) + publishRuntime(.init(status: .unknown)) + } else if shellIntegrationEnabled { + publishRuntime(.init(status: .unavailable)) + } + } + let variables = environment.map { ($0.0, SSHPTYStartupBytes($0.1), SSHPTYStartupBytes($0.2)) } + let initialSize = try await queue.perform { self.ptyResize.requestedSize } + guard let budget = try await queue.perform({ self.channelBudget }) else { throw TermindSSHError.disconnected } + let lease = try await budget.acquire() + try await queue.perform { [self] in + guard ptyResize.generation == generation, connection === opened else { + lease.release() + throw CancellationError() + } + ptyChannelLease = lease + } + // Each libssh2 retry stays queue-confined, while selection and network waits never block the pump. + for operation in 0..<(4 + environment.count) { + try await retryPTYOperation(generation: generation, retriesChannelOpen: operation == 0) { [self] in + let session = opened.libssh2Session + switch operation { + case 0: + openingPTY = libssh2_channel_open_ex(session, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0) + return openingPTY != nil ? 0 : libssh2_session_last_errno(session) + case 1: + guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED } + return request.agentForwarding == nil ? 0 : libssh2_channel_request_auth_agent(channel) + case 2: + guard let channel = openingPTY, let size = initialSize else { + return LIBSSH2_ERROR_CHANNEL_CLOSED + } + let result = libssh2_channel_request_pty_ex( + channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows), + Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)) + if result == 0 { ptyResize.didCreatePTY(size: size) } + return result + case 3..<(3 + environment.count): + let variable = variables[operation - 3] + let result = libssh2_channel_setenv_ex( + openingPTY, variable.1.pointer, variable.1.count, variable.2.pointer, variable.2.count) + if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED { + environmentRejection([variable.0]) + return 0 + } + return result + default: + if let command { + return libssh2_channel_process_startup(openingPTY, "exec", 4, command.pointer, command.count) + } + return libssh2_channel_process_startup(openingPTY, "shell", 5, nil, 0) + } + } + } + try await queue.perform { [self] in + guard ptyResize.generation == generation, connection === opened else { throw CancellationError() } + ptyChannel = openingPTY + openingPTY = nil + retainsControlConnection = launch != nil + inputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding) + outputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) + workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: charset) + didReportExit = false + progress?(.connected(request.configuration.hostID)) + pump() + } + } + + private func retryPTYOperation( + generation: UUID?, retriesChannelOpen: Bool = false, operation: @escaping @Sendable () -> Int32 + ) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(20)) + while true { + try Task.checkCancellation() + let result = try await queue.perform { [self] in + guard connection != nil, generation == nil || ptyResize.generation == generation else { + throw CancellationError() + } + return operation() + } + if result == 0 { return } + guard result == LIBSSH2_ERROR_EAGAIN || (retriesChannelOpen && result == LIBSSH2_ERROR_CHANNEL_FAILURE) + else { + throw TermindSSHError.library("SSH terminal request failed (\(result)).") + } + guard ContinuousClock.now < deadline else { + throw TermindSSHError.library("SSH terminal request timed out.") + } + try await Task.sleep(for: .milliseconds(10)) + } + } + + func releaseTerminalForControl(expectedGeneration: UUID? = nil) async throws { + let generation = try await queue.perform { [self] in + guard connection != nil else { throw TermindSSHError.disconnected } + guard expectedGeneration == nil || expectedGeneration == ptyResize.generation else { + throw CancellationError() + } + stopRuntimeObservation() + retainsControlConnection = true + didReportExit = true + if let ptyChannel { + openingPTY = ptyChannel + self.ptyChannel = nil + } + pendingWrites.removeAll() + return ptyResize.generation + } + try await retryPTYOperation(generation: generation) { [self] in + guard let openingPTY else { return 0 } + return libssh2_channel_close(openingPTY) + } + try await retryPTYOperation(generation: generation) { [self] in + guard let openingPTY else { return 0 } + guard libssh2_channel_eof(openingPTY) == 1 else { return 0 } + return libssh2_channel_wait_closed(openingPTY) + } + try await retryPTYOperation(generation: generation) { [self] in + guard let openingPTY else { return 0 } + let result = libssh2_channel_free(openingPTY) + if result == 0 { self.openingPTY = nil } + return result + } + await queue.perform { [self] in + guard ptyResize.generation == generation else { return } + ptyChannelLease?.release() + ptyChannelLease = nil + } + } + public static func authenticate( username: String, credentials: [SSHCredentials], session: OpaquePointer, keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1, @@ -1534,8 +1927,33 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } if let ptyChannel, libssh2_channel_eof(ptyChannel) == 1 { let status = libssh2_channel_get_exit_status(ptyChannel) + if retainsControlConnection { + // Keep the authenticated transport, but finish CLOSE/FREE before allowing + // a post-exit probe to consume the server's only session slot. + openingPTY = ptyChannel + self.ptyChannel = nil + let generation = ptyResize.generation + Task { [self] in + do { + try await releaseTerminalForControl(expectedGeneration: generation) + await queue.perform { [self] in + guard ptyResize.generation == generation else { return } + didReportExit = false + reportExit(.success(status)) + } + } catch { + await queue.perform { [self] in + guard ptyResize.generation == generation else { return } + didReportExit = false + reportExit(.failure(.disconnected)) + cleanup() + } + } + } + return + } reportExit(.success(status)) - closeTerminalSession() + closeTerminalSession(preservingControl: retainsControlConnection) return } do { try buffer.flush(socket: socket) } catch { @@ -1596,6 +2014,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func deliverIntegratedOutput(_ data: Data) { + if var contextFilter { + let update = contextFilter.receive(data) + self.contextFilter = contextFilter + if let terminal = update.terminal { + runtimeTerminal = terminal + startRuntimeObservation() + } + for context in update.contexts { + runtimeRevision &+= 1 + runtimeWantsObservation = context.status != .ordinary + publishRuntime(context) + if runtimeWantsObservation { startRuntimeObservation() } + } + if !update.output.isEmpty { emitConvertedOutput(update.output) } + return + } guard var commandEventFilter else { emitConvertedOutput(data) return @@ -1617,6 +2051,171 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + private func startRuntimeObservation() { + guard runtimeEnabled, runtimeTask == nil, runtimeTerminal != nil else { return } + let generation = ptyResize.generation + runtimeTask = Task { [weak self] in + guard let self else { return } + var delay = 500 + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(delay)) + guard !Task.isCancelled else { break } + let request = try? await queue.perform { [self] () -> (String, UInt64, RemoteCharset)? in + guard runtimeEnabled, runtimeWantsObservation, ptyResize.generation == generation, + !runtimeControlUnavailable, let terminal = runtimeTerminal + else { return nil } + return (terminal, runtimeRevision, charset) + } + guard let request else { continue } + do { + let adapter = RemoteTmuxRuntimeAdapter( + execute: try await sessionCommandExecutor(runtime: true), charset: request.2) + let update = try await adapter.inspect(terminal: request.0) + try Task.checkCancellation() + await queue.perform { [self] in + guard ptyResize.generation == generation, runtimeRevision == request.1, + runtimeWantsObservation + else { return } + publishRuntime(update) + } + delay = update.status == .multiplexer ? 750 : min(delay * 2, 5000) + } catch { + guard !Task.isCancelled else { break } + await queue.perform { [self] in + guard ptyResize.generation == generation, runtimeRevision == request.1, + runtimeWantsObservation + else { return } + publishRuntime(.init(status: .unavailable)) + } + delay = min(delay * 2, 5000) + } + } + } + } + + private func publishRuntime(_ proposed: TerminalRuntimeUpdate) { + var update = proposed + if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false { + if update.status == .multiplexer { + update = .init(status: .unavailable) + } else { + for index in update.contexts.indices { + update.contexts[index].capabilities.subtract([.readDraft, .replaceDraft]) + } + } + } + latestRuntimeUpdate = update + if update.status == .ordinary, + let context = update.contexts.first(where: { $0.identity == update.active }), + let path = context.historyPath, let shell = ShellHistorySource.Shell(rawValue: context.shell) + { + detectedHistorySource = .init(shell: shell, path: path) + } + shellIntegrationHandler(.runtime(update)) + } + + func confirmContext(_ expected: TerminalShellContext) async throws { + let state = try await queue.perform { [self] in + guard let terminal = runtimeTerminal, latestRuntimeUpdate?.active == expected.identity else { + throw TerminalRuntimeError.staleContext + } + return (terminal, runtimeInputRevision, ptyResize.generation, charset) + } + if expected.identity.backend != "shell" { + let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.1) + let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.3).inspect( + terminal: state.0) + guard update.active == expected.identity, + update.contexts.first(where: { $0.identity == expected.identity }) == expected + else { throw TerminalRuntimeError.staleContext } + } + try Task.checkCancellation() + try await queue.perform { [self] in + guard runtimeInputRevision == state.1, ptyResize.generation == state.2, + latestRuntimeUpdate?.active == expected.identity, + latestRuntimeUpdate?.contexts.first(where: { $0.identity == expected.identity }) == expected + else { throw TerminalRuntimeError.staleContext } + } + } + + func contextDraft(_ expected: TerminalShellContext, replacing replacement: String?) async throws -> String { + let request = UUID() + let state = try await queue.perform { [self] in + guard pendingRuntimeDraft == nil, let terminal = runtimeTerminal, + latestRuntimeUpdate?.active == expected.identity, expected.promptReady, + !runtimeControlUnavailable, + latestRuntimeUpdate?.contexts.first(where: { $0.identity == expected.identity })? + .capabilities.contains(replacement == nil ? .readDraft : .replaceDraft) == true + else { throw TerminalRuntimeError.staleContext } + pendingRuntimeDraft = request + return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset) + } + do { + let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.2) + let update: TerminalRuntimeUpdate? + if expected.identity.backend == "tmux" { + update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.4).inspect( + terminal: state.0) + guard update?.active == expected.identity, + update?.contexts.first(where: { $0.identity == expected.identity }) == expected + else { throw TerminalRuntimeError.staleContext } + } else { + update = nil + } + try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3) + let command = try RemoteTerminalDraft.command( + context: expected, terminal: state.0, helper: state.1, + clientIdentity: update?.clientIdentity, request: request, replacement: replacement, + original: try await queue.perform { [self] in + replacement != nil && runtimeDraftBaseline?.0 == expected.identity ? runtimeDraftBaseline?.1 : nil + }, charset: state.4) + let started = try await execute(command) + guard started.exitCode == 0, !started.truncated else { throw TerminalRuntimeError.staleContext } + if expected.identity.backend == "shell" { + try await queue.perform { [self] in + guard pendingRuntimeDraft == request, runtimeInputRevision == state.2, + ptyResize.generation == state.3, latestRuntimeUpdate?.active == expected.identity + else { throw TerminalRuntimeError.staleContext } + let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~" + pendingWrites.append(Data(key.utf8)) + pump() + } + } + let result = try await execute( + RemoteTerminalDraft.resultCommand(context: expected, helper: state.1, request: request)) + guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.staleContext } + let record = try RemoteShellContextRecord(result.stdout, charset: state.4) + guard record.instance == expected.identity.shellInstance, + let value = record.entries.compactMap({ entry -> String? in + if case .draft(let id, let value) = entry.1, id == request.uuidString { return value } + return nil + }).last + else { throw TerminalRuntimeError.staleContext } + try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3) + await queue.perform { [self] in + if pendingRuntimeDraft == request { + pendingRuntimeDraft = nil + runtimeDraftBaseline = (expected.identity, value) + } + } + return value + } catch { + await queue.perform { [self] in if pendingRuntimeDraft == request { pendingRuntimeDraft = nil } } + throw error + } + } + + private func validateDraft( + _ request: UUID, context: TerminalContextIdentity, input: UInt64, generation: UUID? + ) async throws { + try Task.checkCancellation() + try await queue.perform { [self] in + guard pendingRuntimeDraft == request, runtimeInputRevision == input, ptyResize.generation == generation, + latestRuntimeUpdate?.active == context + else { throw TerminalRuntimeError.staleContext } + } + } + private func emitConvertedOutput(_ data: Data) { do { var filteredData = data @@ -1650,7 +2249,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } catch {} } - private func closeTerminalSession() { + private func closeTerminalSession(preservingControl: Bool = false) { + stopRuntimeObservation() + retainsControlConnection = preservingControl execChannelOwner.cancelAllOnQueue() detectedHistorySource = nil buffer?.agentChannels?.shutdown() @@ -1674,7 +2275,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } // Cancelled exec callers may already have returned while native close still waits // for the peer. Only live SFTP channels justify keeping this transport running. - if sftpChannelOwner.isEmptyOnQueue { + if sftpChannelOwner.isEmptyOnQueue && !retainsControlConnection { cleanup() } else { queue.async { [weak self] in self?.pump() } @@ -1682,6 +2283,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func cleanup() { + stopRuntimeObservation() + retainsControlConnection = false buffer?.agentChannels?.shutdown() pumpTimer?.cancel() pumpTimer = nil @@ -1702,6 +2305,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { _ = libssh2_channel_free(ptyChannel) self.ptyChannel = nil } + if let openingPTY { + _ = libssh2_channel_close(openingPTY) + _ = libssh2_channel_free(openingPTY) + self.openingPTY = nil + } execChannelOwner.closeAllOnQueue(error: CancellationError()) detectedHistorySource = nil sftpChannelOwner.closeAllOnQueue() @@ -1718,6 +2326,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + private func stopRuntimeObservation() { + runtimeTask?.cancel() + runtimeTask = nil + runtimeTerminal = nil + runtimeEnabled = false + runtimeWantsObservation = false + runtimeControlUnavailable = false + runtimeRevision &+= 1 + contextFilter = nil + latestRuntimeUpdate = nil + runtimeHelper = nil + pendingRuntimeDraft = nil + runtimeDraftBaseline = nil + runtimeInputRevision &+= 1 + } + public static func observation(host: String, port: Int, session: OpaquePointer) throws -> SSHHostKeyObservation { @@ -1884,7 +2508,11 @@ nonisolated extension SSHPTYChannelWorker { try Task.checkCancellation() let (source, pending) = try await queue.perform { [self] in guard connection != nil else { throw TermindSSHError.disconnected } - return (detectedHistorySource, shellIntegration != nil) + return ( + detectedHistorySource, + shellIntegration != nil + || (runtimeEnabled && runtimeHelper != nil && latestRuntimeUpdate?.active == nil) + ) } if !pending || source != nil || ContinuousClock.now >= deadline { return source } try await Task.sleep(for: .milliseconds(50)) diff --git a/Scripts/ssh-tmux-session-fixture.py b/Scripts/ssh-tmux-session-fixture.py new file mode 100644 index 00000000..47df0e7f --- /dev/null +++ b/Scripts/ssh-tmux-session-fixture.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Isolated Lima/OpenSSH fixture for remote persistence and terminal context tests. + +Requires a running Lima VM with nerdctl. Creates a disposable Debian container, +loopback-only SSH port and ephemeral key. Never mounts or edits a user's SSH +configuration. Stop with Ctrl-C to remove the container, keys and metadata. + +Run once with --max-sessions 1 for refusal/degradation and once with +--max-sessions 8 for runtime context, draft and multi-pane acceptance. +Use --metadata /tmp/termind-tmux.json, then: +TEST_RUNNER_TERMIND_TMUX_FIXTURE=/tmp/termind-tmux.json xcodebuild \ + -project Termind.xcodeproj -scheme TFM -destination 'platform=macOS' \ + -onlyUsePackageVersionsFromResolvedFile \ + -only-testing:TFMTests/RemoteSessionWireTests \ + -only-testing:TFMTests/TerminalContextRuntimeTests \ + -parallel-testing-enabled NO test +""" + +import argparse +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import tempfile +import time +import uuid + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metadata", required=True) + parser.add_argument("--lima", default="default") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--max-sessions", type=int, default=1) + args = parser.parse_args() + metadata = Path(args.metadata).resolve() + if metadata.exists(): + parser.error("Metadata already exists; choose a new path.") + if not 0 <= args.port <= 65535: + parser.error("Port must be between 0 and 65535.") + if not 1 <= args.max_sessions <= 32: + parser.error("MaxSessions must be between 1 and 32.") + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", args.port)) + port = reservation.getsockname()[1] + name = "termind-tmux-" + uuid.uuid4().hex[:12] + + def nerdctl(*command, **kwargs): + kwargs.setdefault("check", True) + return subprocess.run( + ["limactl", "shell", args.lima, "nerdctl", *command], + text=True, **kwargs) + + def execute(command, **kwargs): + return nerdctl("exec", "-i", name, "sh", "-c", command, **kwargs) + + created_metadata = False + with tempfile.TemporaryDirectory(prefix="termind-tmux-fixture-") as directory: + root = Path(directory) + key = root / "client" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + try: + nerdctl( + "run", "--detach", "--name", name, "--publish", f"127.0.0.1:{port}:22", + "debian:trixie-slim", "sleep", "infinity", stdout=subprocess.DEVNULL) + print("Installing OpenSSH, tmux and shells in an isolated Lima container...", flush=True) + execute( + "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends " + "openssh-server tmux bash zsh fish vim procps >/dev/null") + execute( + "umask 077; mkdir -p /root/.ssh /run/sshd; cat > /root/.ssh/authorized_keys", + input=key.with_suffix(".pub").read_text()) + execute("cat > /tmp/termind-sshd.conf", input=f"""Port 22 +ListenAddress 0.0.0.0 +HostKey /etc/ssh/ssh_host_ed25519_key +PidFile /tmp/termind-sshd.pid +AuthorizedKeysFile .ssh/authorized_keys +PermitRootLogin prohibit-password +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +AuthenticationMethods publickey +UsePAM no +MaxSessions {args.max_sessions} +AcceptEnv SHELL HOME XDG_CONFIG_HOME LC_ALL TERMIND_TMUX_SHELL +AllowTcpForwarding no +AllowAgentForwarding no +X11Forwarding no +LogLevel ERROR +""") + host_key = root / "host.pub" + host_key.write_text(execute( + "cat /etc/ssh/ssh_host_ed25519_key.pub", capture_output=True).stdout) + fingerprint = subprocess.check_output( + ["ssh-keygen", "-lf", str(host_key), "-E", "sha256"], text=True).split()[1] + nerdctl("exec", "--detach", name, "/usr/sbin/sshd", "-D", "-e", "-f", "/tmp/termind-sshd.conf") + for _ in range(200): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1) as connection: + connection.settimeout(0.2) + if connection.recv(256).startswith(b"SSH-"): + break + except OSError: + pass + time.sleep(0.1) + else: + raise RuntimeError("Lima did not forward the fixture's SSH listener.") + descriptor = os.open(metadata, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + created_metadata = True + with os.fdopen(descriptor, "w") as output: + json.dump({ + "port": port, "hostFingerprint": fingerprint, "privateKeyPath": str(key), + "maxSessions": args.max_sessions + }, output) + print(f"Fixture ready: {metadata} (MaxSessions={args.max_sessions}, loopback port {port})", flush=True) + while True: + signal.pause() + except KeyboardInterrupt: + pass + finally: + nerdctl("rm", "--force", name, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if created_metadata: + metadata.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index 2d9bbbba..a91823e6 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -18,7 +18,6 @@ struct MobileAppDependencies { var logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator() var container: ModelContainer? = nil - var workspaceStore: MobileWorkspaceStore? = nil var liveActivities: MobileLiveActivityController? = nil var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void = MobileWorkspaceSceneRouting.createWindow diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 05ce42dd..a6485d95 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -133,14 +133,14 @@ final class MobileAppModel { private let snippetTimestampFallback: Date private let background: any MobileBackgroundTaskManaging let workspaceConnectionServices: MobileWorkspaceConnectionServices - private let workspaceStore: MobileWorkspaceStore - private var workspaceSnapshot = MobileWorkspaceStore.Snapshot() - private var hasBoundWorkspaceSession = false + let recentlyClosedStore = RecentlyClosedStore>( + defaults: nil, key: "mobile.recentlyClosed.v2") private var orphanedSessionIDs: Set = [] // Disconnect and discard are separate UIKit callbacks. A later explicit discard must still close // precisely the workspaces recovered from that window, even if another window already received them. private var recoveredWorkspaceIDs: [String: Set] = [:] private var discardedSessionIDs: Set = [] + private var activityRebuilds: [UUID: UUID] = [:] var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:] var pendingClosedWindows: Set = [] var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void @@ -184,9 +184,7 @@ final class MobileAppModel { openWorkspaceSessionIDs = dependencies.openWorkspaceSessionIDs self.background = background self.workspaceConnectionServices = workspaceConnectionServices - workspaceStore = - dependencies.workspaceStore - ?? MobileWorkspaceStore(defaults: inMemory || hosts != nil ? nil : defaults) + MobileWorkspaceStore.retire(in: defaults ?? .standard) appReview = dependencies.appReview diagnosis = dependencies.diagnosis logging = dependencies.logging @@ -434,7 +432,6 @@ final class MobileAppModel { metricsSync?.didApplyRemote = { [weak self] snapshot, _ in self?.metrics.core.applyRemoteSnapshot(snapshot) } - loadRecentWorkspaces() } private func reconcileConnectionPreferences() { @@ -474,11 +471,6 @@ final class MobileAppModel { session.close(item.id, rememberForReopen: false) } } - workspaceSnapshot.unassigned.removeAll { $0.hostID == hostID } - for id in workspaceSnapshot.sessions.keys { - workspaceSnapshot.sessions[id]?.removeAll { $0.hostID == hostID } - } - persistWorkspaceSnapshot() } @discardableResult @@ -560,16 +552,6 @@ final class MobileAppModel { guard !target.isDiscarded else { return } for source in workspaceSessions where orphanedSessionIDs.contains(source.id) { let recovery = source.recovery - var snapshot = workspaceSnapshot - snapshot.sessions[source.id] = nil - snapshot.sessions[target.id] = target.savedWorkspaceDescriptors + recovery.descriptors - do { - try workspaceStore.save(snapshot) - workspaceSnapshot = snapshot - } catch { - self.error = String(localized: AppStrings.Workspace.workspaceChangesCouldNotBeSavedForTheNextLaunch) - continue - } recoveredWorkspaceIDs[source.id, default: []].formUnion(recovery.order) source.releaseForRecovery() target.acceptRecovery(recovery) @@ -705,26 +687,7 @@ final class MobileAppModel { func workspaceSession(for id: String) -> MobileWorkspaceSessionModel { if let session = workspaceSessions.first(where: { $0.id == id }) { return session } - var descriptors = workspaceSnapshot.sessions[id] ?? [] - if !hasBoundWorkspaceSession, !discardedSessionIDs.contains(id) { - // A new process starts with one workspace window, even if UIKit restores several old scenes. - // Keep records until ownership and the full consolidation can be saved together. - var migrated = workspaceSnapshot - descriptors = migrated.unassigned + migrated.sessions.keys.sorted().flatMap { migrated.sessions[$0] ?? [] } - migrated.unassigned = [] - migrated.sessions = [id: descriptors] - do { - try workspaceStore.save(migrated) - workspaceSnapshot = migrated - hasBoundWorkspaceSession = true - } catch { - // Fence this launch too: another window must not claim the same recovery records. - hasBoundWorkspaceSession = true - descriptors = [] - self.error = String(localized: AppStrings.Workspace.restoreWorkspacesFailure) - } - } - let session = MobileWorkspaceSessionModel(id: id, app: self, descriptors: descriptors) + let session = MobileWorkspaceSessionModel(id: id, app: self, descriptors: []) workspaceSessions.append(session) if discardedSessionIDs.contains(id) { session.discard() } return session @@ -735,11 +698,41 @@ final class MobileAppModel { { return session.id } - return workspaceSnapshot.sessions.first(where: { $0.value.contains { $0.id == workspaceID } })?.key + return nil } - func discardWorkspaceSessions(_ ids: Set) { + /// Main-actor publication happens before authentication, merging concurrent scene/link deliveries. + func rebuildActivityWorkspace(from url: URL, into session: MobileWorkspaceSessionModel) -> UUID? { + guard let source = WorkspaceActivityAttributes.workspaceID(from: url) else { return nil } + if containsWorkspace(source) { return source } + if let rebuilt = activityRebuilds[source], containsWorkspace(rebuilt) { return rebuilt } + let fallback = WorkspaceActivityAttributes.recovery(from: url) + guard let recovery = liveActivities.recovery(for: source) ?? fallback, + recovery.version == 1, recovery.sourceWorkspaceID == source, + fallback == nil || (fallback?.hostID == recovery.hostID && fallback?.kind == recovery.kind), + let id = session.rebuild(recovery) + else { return nil } + activityRebuilds[source] = id + liveActivities.remove(source) + liveActivities.update(workspaceActivitySessions) + return id + } + + func discardWorkspaceSessions(_ ids: Set, authorized: Bool = false) { guard !ids.isEmpty else { return } + let recovered = ids.reduce(into: Set()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) } + let targets = workspaceSessions.flatMap { session in + session.workspaces.filter { ids.contains(session.id) || recovered.contains($0.id) } + } + if !authorized, + deferPersistentClose( + targets, + action: { [weak self] in + self?.discardWorkspaceSessions(ids, authorized: true) + }) + { + return + } discardedSessionIDs.formUnion(ids) orphanedSessionIDs.subtract(ids) for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) } @@ -750,63 +743,23 @@ final class MobileAppModel { .filter { recovered.contains($0.id) } .map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } rememberClosedWorkspaces(states, isWindow: true) - } else if hasBoundWorkspaceSession, !workspaceSessions.contains(where: { $0.id == id }), - let descriptors = workspaceSnapshot.sessions[id] - { - rememberClosedWorkspaces(descriptors.map { .init(descriptor: $0) }, isWindow: true) + } } let recoveredIDs = ids.reduce(into: Set()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) } for session in workspaceSessions { - for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false) } + for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false, authorized: true) } } for id in ids { recoveredWorkspaceIDs[id] = nil } - for session in workspaceSessions where ids.contains(session.id) { session.discard() } + for session in workspaceSessions where ids.contains(session.id) { session.discard(authorized: true) } workspaceSessions.removeAll { ids.contains($0.id) } - // Before the first binding, these are records from the previous process awaiting consolidation. - // UIKit discarding an old session must not erase them during cold launch. - if hasBoundWorkspaceSession { - for id in ids { workspaceSnapshot.sessions[id] = nil } - persistWorkspaceSnapshot() - } + } func containsWorkspace(_ id: UUID) -> Bool { workspaceSessions.contains { session in session.orderedWorkspaceItems.contains { $0.id == id } } } - var recentlyClosedStore: RecentlyClosedStore> { - workspaceStore.recentlyClosed - } - - func saveWorkspaceDescriptors(_ descriptors: [MobileWorkspaceDescriptor], for id: String) { - guard !discardedSessionIDs.contains(id) else { return } - workspaceSnapshot.sessions[id] = descriptors - persistWorkspaceSnapshot() - } - - private func loadRecentWorkspaces() { - do { - workspaceSnapshot = try workspaceStore.load() - workspaceSnapshot.unassigned.removeAll { library.host($0.hostID) == nil } - for id in workspaceSnapshot.sessions.keys { - workspaceSnapshot.sessions[id]?.removeAll { library.host($0.hostID) == nil } - } - // Persist pruning with the first binding, so legacy migration and ownership stay atomic. - } catch { - self.error = String(localized: AppStrings.Workspace.restoreWorkspacesFailure) - } - liveActivities.update([]) - } - - private func persistWorkspaceSnapshot() { - do { - try workspaceStore.save(workspaceSnapshot) - } catch { - self.error = String(localized: AppStrings.Workspace.workspaceChangesCouldNotBeSavedForTheNextLaunch) - } - } - func isAssistantNotificationFocused(_ id: UUID) -> Bool { paneScenes.values.compactMap(\.value).contains { $0.isShowingAssistant(id) } } @@ -876,6 +829,38 @@ final class MobileAppModel { return available.first(where: isNotificationSceneActive) ?? available.first } + let persistentClose = RemoteSessionCloseCoordinator() + + func authorizePersistentClose(_ targets: [MobileWorkspace]) async -> Bool { + let owners = Dictionary(uniqueKeysWithValues: targets.map { ($0.id, workspaceOwner($0.id)) }) + let ownerIDs = Set(targets.compactMap { workspaceOwner($0.id) }) + let snapshot = workspaceSessions.filter { ownerIDs.contains($0.id) }.map { $0.orderedWorkspaceItems.map(\.id) } + let allowed = await persistentClose.close(attachments: targets.compactMap { $0.sessionPersistence.attachment }) + { + reference in + guard let workspace = targets.first(where: { $0.sessionPersistence.attachment == reference }), + self.containsWorkspace(workspace.id), self.workspaceOwner(workspace.id) == owners[workspace.id] + else { throw CancellationError() } + try await workspace.terminatePersistentSession(reference) { + self.containsWorkspace(workspace.id) && self.workspaceOwner(workspace.id) == owners[workspace.id] + } + for workspace in targets + where workspace.sessionPersistence.attachment?.hasSameIdentity(as: reference) == true { + workspace.sessionPersistence.markEnded() + } + } + return allowed + && snapshot + == workspaceSessions.filter { ownerIDs.contains($0.id) }.map { $0.orderedWorkspaceItems.map(\.id) } + } + + func deferPersistentClose(_ targets: [MobileWorkspace], action: @escaping @MainActor () -> Void) -> Bool { + guard targets.contains(where: { $0.sessionPersistence.attachment?.isManaged == true }) else { return false } + guard !persistentClose.isClosing else { return true } + Task { if await authorizePersistentClose(targets) { action() } } + return true + } + func workspaceWillOpen(_ workspace: MobileWorkspace, close: @escaping (UUID) -> Void) { terminalNotifications.observe(workspace.commandMonitor, id: workspace.id) @@ -891,6 +876,11 @@ final class MobileAppModel { func workspaceDidClose(_ id: UUID) { endConnectionHistory(for: id) + liveActivities.remove(id) + for source in activityRebuilds.keys where activityRebuilds[source] == id { + liveActivities.remove(source) + } + activityRebuilds = activityRebuilds.filter { $0.value != id } workspaceActivitySessions.removeAll { $0.id == id } liveActivities.update(workspaceActivitySessions) } diff --git a/TFI/App/MobileRecentlyClosedWorkspaces.swift b/TFI/App/MobileRecentlyClosedWorkspaces.swift index 4793acb1..8c5b5b76 100644 --- a/TFI/App/MobileRecentlyClosedWorkspaces.swift +++ b/TFI/App/MobileRecentlyClosedWorkspaces.swift @@ -23,11 +23,7 @@ extension MobileAppModel { guard !states.isEmpty else { return } let record = RecentlyClosedRecord(items: states.map(\.descriptor), isWindow: isWindow) closedWorkspaceStates[record.id] = states - recentlyClosedStore.record( - record, - persist: states.allSatisfy { - $0.connectionOrigin.allowsPersistence && library.host($0.descriptor.hostID) != nil - }) + recentlyClosedStore.record(record, persist: false) let retained = Set(recentlyClosedStore.entries.map(\.id)) closedWorkspaceStates = closedWorkspaceStates.filter { retained.contains($0.key) } } diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index ab216c73..3050b9bb 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -26,6 +26,10 @@ struct MobileRootView: View { } var body: some View { + content.overlay { RemoteSessionClosePromptView(coordinator: app.persistentClose) } + } + + private var content: some View { featureTabs .background { MobileWorkspaceNumberShortcuts(scene: scene) } .modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications)) diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index a1beacbe..afce2676 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -386,18 +386,34 @@ final class MobileSceneModel { requestWindowClose(sessionID: workspaceSession.id) } - func closeAllWindows() { + func closeAllWindows(authorized: Bool = false) { guard !workspaceSession.isDiscarded else { return } let sessionIDs = app.openWorkspaceSessionIDs() + if !authorized, + app.deferPersistentClose( + sessionIDs.flatMap { app.workspaceSession(for: $0).workspaces }, + action: { [weak self] in self?.closeAllWindows(authorized: true) }) + { + return + } for id in sessionIDs.sorted() where id != workspaceSession.id { - requestWindowClose(sessionID: id) + requestWindowClose(sessionID: id, authorized: true) } if sessionIDs.contains(workspaceSession.id) { - requestWindowClose(sessionID: workspaceSession.id) + requestWindowClose(sessionID: workspaceSession.id, authorized: true) } } - private func requestWindowClose(sessionID: String) { + private func requestWindowClose(sessionID: String, authorized: Bool = false) { + if !authorized, + app.deferPersistentClose( + app.workspaceSession(for: sessionID).workspaces, + action: { [weak self] in + self?.requestWindowClose(sessionID: sessionID, authorized: true) + }) + { + return + } // UIKit's discard callback owns cleanup; a rejected request must preserve this session. let logger = app.logging.makeLogger(label: "termind.window") app.closeWorkspaceSession(sessionID) { error in @@ -454,9 +470,19 @@ final class MobileSceneModel { workspaceID = nil return } - guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return } + guard let sourceID = WorkspaceActivityAttributes.workspaceID(from: url) else { + if url.scheme == "termind", url.host == "workspace" { + pendingWorkspaceID = nil + selectedDestination = .workspaces + workspaceID = nil + isWorkspaceUnavailable = true + } + return + } isWorkspaceUnavailable = false - guard let owner = app.workspaceOwner(id) else { + let id = + app.containsWorkspace(sourceID) ? sourceID : app.rebuildActivityWorkspace(from: url, into: workspaceSession) + guard let id, let owner = app.workspaceOwner(id) else { pendingWorkspaceID = nil selectedDestination = .workspaces workspaceID = nil diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index bb100e7a..2f653fa9 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -22,7 +22,7 @@ struct MobileWorkspaceItem: Identifiable { var id: MobileWorkspaceDescriptor.ID { descriptor.id } } -/// Connection intent and live workspaces belonging to one persistent UIKit scene session. +/// In-process connection intent and live workspaces belonging to one native scene. @MainActor @Observable final class MobileWorkspaceSessionModel { @@ -79,6 +79,7 @@ final class MobileWorkspaceSessionModel { } func releaseForRecovery() { + app.persistentClose.cancel() manuallyUnreadIDs.removeAll() workspaces.removeAll() savedWorkspaceDescriptors.removeAll() @@ -95,18 +96,18 @@ final class MobileWorkspaceSessionModel { } } - func discard() { + func discard(authorized: Bool = false) { guard !isDiscarded else { return } + if !authorized, app.deferPersistentClose(workspaces, action: { [weak self] in self?.discard(authorized: true) }) + { + return + } let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } app.rememberClosedWorkspaces(states, isWindow: true) - closeAll(rememberForReopen: false) + closeAll(rememberForReopen: false, authorized: true) isDiscarded = true } - private func saveWorkspaces() { - app.saveWorkspaceDescriptors(savedWorkspaceDescriptors, for: id) - } - var recentWorkspaces: [MobileWorkspaceDescriptor] { let opened = Set(workspaces.map(\.id)) return savedWorkspaceDescriptors.filter { !opened.contains($0.id) } @@ -152,6 +153,23 @@ final class MobileWorkspaceSessionModel { return workspace.id } + func rebuild(_ recovery: WorkspaceActivityAttributes.Recovery) -> UUID? { + guard !isDiscarded, let host = app.library.host(recovery.hostID), + let kind = ConnectionHistoryEntry.Kind(rawValue: recovery.kind), + MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) + else { return nil } + let workspace = makeWorkspace(host: host, kind: kind) + if let data = recovery.remoteSession, + let reference = try? JSONDecoder().decode(RemoteSessionReference.self, from: data), + reference.hostID == host.id, kind == .ssh + { + workspace.restoreRemoteSession(reference) + } + workspace.sessionPersistence.requiresSelection = true + addWorkspaces([workspace]) + return workspace.id + } + @discardableResult func batchStartup( hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol @@ -230,11 +248,19 @@ final class MobileWorkspaceSessionModel { if let savedIndex, let workspace { savedWorkspaceDescriptors[savedIndex] = workspace.descriptor } - saveWorkspaces() return true } - func close(_ id: UUID, rememberForReopen: Bool = true) { + func close(_ id: UUID, rememberForReopen: Bool = true, authorized: Bool = false) { + if !authorized, + app.deferPersistentClose( + workspaces.filter { $0.id == id }, + action: { [weak self] in + self?.close(id, rememberForReopen: rememberForReopen, authorized: true) + }) + { + return + } manuallyUnreadIDs.remove(id) let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id }) guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return } @@ -248,14 +274,22 @@ final class MobileWorkspaceSessionModel { workspaces.removeAll { $0.id == id } savedWorkspaceDescriptors.removeAll { $0.id == id } workspaceDisplayOrder.removeAll { $0 == id } - if wasSaved { saveWorkspaces() } } var closableWorkspaceCount: Int { Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)).count } - func closeOtherWorkspaces(than id: MobileWorkspace.ID) { + func closeOtherWorkspaces(than id: MobileWorkspace.ID, authorized: Bool = false) { + if !authorized, + app.deferPersistentClose( + workspaces.filter { $0.id != id }, + action: { [weak self] in + self?.closeOtherWorkspaces(than: id, authorized: true) + }) + { + return + } let knownIDs = Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)) guard knownIDs.contains(id), knownIDs.count > 1 else { return } rememberClosedWorkspaces(knownIDs.subtracting([id])) @@ -265,7 +299,6 @@ final class MobileWorkspaceSessionModel { workspaces.removeAll { $0.id != id } savedWorkspaceDescriptors.removeAll { $0.id != id } workspaceDisplayOrder.removeAll { $0 != id } - saveWorkspaces() for workspace in closing { app.workspaceDidClose(workspace.id) workspace.didChangeState = nil @@ -274,7 +307,16 @@ final class MobileWorkspaceSessionModel { } } - func closeAll(rememberForReopen: Bool = true) { + func closeAll(rememberForReopen: Bool = true, authorized: Bool = false) { + if !authorized, + app.deferPersistentClose( + workspaces, + action: { [weak self] in + self?.closeAll(rememberForReopen: rememberForReopen, authorized: true) + }) + { + return + } manuallyUnreadIDs.removeAll() guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return } if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder)) } @@ -282,7 +324,6 @@ final class MobileWorkspaceSessionModel { workspaces.removeAll() savedWorkspaceDescriptors.removeAll() workspaceDisplayOrder.removeAll() - saveWorkspaces() for workspace in closing { app.workspaceDidClose(workspace.id) workspace.didChangeState = nil @@ -327,6 +368,7 @@ final class MobileWorkspaceSessionModel { usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin, externalSFTPResourcePath: state.externalSFTPResourcePath, externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) + workspace.restoreRemoteSession(state.remoteSession) addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) } } @@ -401,7 +443,6 @@ final class MobileWorkspaceSessionModel { if persists { let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) }) - saveWorkspaces() } app.workspacesDidOpen(added) for workspace in added { workspace.connect() } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 598bc5a2..b40f9cd4 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -214,6 +214,7 @@ struct MobileHostEditor: View { sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: fields.startupSnippetChoice, agentForwardingEnabled: $draft.agentForwardingEnabled, + sessionPersistence: $draft.sessionPersistence, allowsAgentForwarding: !draft.isTailscaleManaged, environmentVariables: $draft.environmentVariables, snippets: library.snippets, diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index d9d8da34..472eb4fa 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -30,6 +30,7 @@ private struct MobilePendingUpload { var context: SFTPTransferContext var connectionGeneration: UUID var insertPaths: Bool + var terminalContext: TerminalContextCoordinator.Ticket? } private struct MobilePendingDownload { @@ -56,6 +57,7 @@ final class MobileWorkspace: Identifiable { private(set) var descriptor: MobileWorkspaceDescriptor var id: UUID { descriptor.id } + let sessionPersistence = RemoteSessionCoordinator() @ObservationIgnored private(set) var activitySession: WorkspaceActivityAttributes.Session @ObservationIgnored var didChangeState: ((WorkspaceActivityAttributes.Session) -> Void)? @ObservationIgnored var didRequestClose: ((ID) -> Void)? @@ -111,6 +113,9 @@ final class MobileWorkspace: Identifiable { let commandMonitor = TerminalCommandMonitor() private var commandObservation: TerminalCommandObservation? private var activeHistoryID: UUID? + let terminalContext = TerminalContextCoordinator() + private var hasRuntimeContext = false + private var contextHistory: [TerminalContextIdentity: UUID] = [:] /// Resolved once at the beginning of each connection attempt. private(set) var isShellIntegrationEnabled = true private var shellIntegrationAvailable = false @@ -298,6 +303,10 @@ final class MobileWorkspace: Identifiable { connected: false, iconAsset: LinuxDistributionIconCatalog.entry( for: (hosts.host(descriptor.hostID) ?? transientHost)?.detectedDistribution)?.asset) + if connectionOrigin.allowsPersistence, hosts.host(descriptor.hostID) != nil { + activitySession.recovery = .init( + sourceWorkspaceID: descriptor.id, hostID: descriptor.hostID, kind: descriptor.kind.rawValue) + } pendingCommand = initialCommand.map { MobilePendingInitialCommand(command: $0, recordsInHistory: recordsInitialCommandInHistory) } @@ -329,6 +338,9 @@ final class MobileWorkspace: Identifiable { } commandObservation = TerminalCommandObservation(state: terminal, monitor: commandMonitor) surfaceOwner.bindFind(find) + sessionPersistence.didChangePrompt = { [weak self] pending in + if pending { self?.journey.waitForSessionSelection() } else { self?.journey.resume() } + } } @discardableResult @@ -504,6 +516,8 @@ final class MobileWorkspace: Identifiable { } func receiveWorkingDirectory(_ path: String?) { + guard descriptor.kind != .ssh || isShellIntegrationEnabled else { return } + guard !hasRuntimeContext else { return } let path = path?.isEmpty == false ? path : nil guard workingDirectory != path else { return } workingDirectory = path @@ -514,6 +528,7 @@ final class MobileWorkspace: Identifiable { var canGenerateFromCurrentDraft: Bool { connected && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady && TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "") + && (!hasRuntimeContext || terminalContext.ticket(requiring: [.readDraft, .replaceDraft]) != nil) } func receiveInteractiveTerminalInput(_ data: Data) async { @@ -526,21 +541,75 @@ final class MobileWorkspace: Identifiable { } private func writeSSHDirectly(_ data: Data) async { + terminalContext.noteInput() guard let ssh else { return } await connectionServices.writeSSH(ssh, data) } private func requestCurrentDraft() async throws -> String { - try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in + if hasRuntimeContext { + guard let ticket = terminalContext.ticket(requiring: .readDraft), + let context = terminalContext.active, let ssh + else { throw TerminalDraftError.unavailable } + let value = try await ssh.contextDraft(context) + guard terminalContext.isCurrent(ticket, requiring: .readDraft) else { + throw TerminalDraftError.unavailable + } + return value + } + return try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in await self?.writeSSHDirectly(data) } } private func replaceCurrentDraft(with command: String) async { + if hasRuntimeContext { + guard let ticket = terminalContext.ticket(requiring: .replaceDraft), + let context = terminalContext.active, let ssh, + terminalContext.isCurrent(ticket, requiring: .replaceDraft) + else { return } + do { + _ = try await ssh.contextDraft(context, replacing: command) + } catch { + if terminalContext.isCurrent(ticket, requiring: .replaceDraft) { + receiveRuntimeContext(.init(status: .unknown)) + } + showCommandGenerationFailure(TerminalDraftError.unavailable.localizedDescription) + } + return + } await writeSSHDirectly(commandDraft.replacementPayload(for: command)) terminal.requestFocus() } + func validatedSameDirectoryLaunch(_ expected: MobileWorkspaceDirectoryLaunch) async + -> MobileWorkspaceDirectoryLaunch? + { + guard sameDirectoryLaunch == expected else { return nil } + do { + try await confirmDirectoryContext(terminalContext.ticket(requiring: .directory)) + return sameDirectoryLaunch == expected ? expected : nil + } catch { + return nil + } + } + + private func confirmDirectoryContext(_ ticket: TerminalContextCoordinator.Ticket?) async throws { + guard hasRuntimeContext else { return } + guard let ticket, terminalContext.isCurrent(ticket, requiring: .directory), + let context = terminalContext.active, let ssh + else { throw TerminalDraftError.unavailable } + do { + try await ssh.confirmContext(context) + guard terminalContext.isCurrent(ticket, requiring: .directory) else { throw TerminalDraftError.unavailable } + } catch { + if terminalContext.isCurrent(ticket, requiring: .directory) { + receiveRuntimeContext(.init(status: .unknown)) + } + throw error + } + } + private func commandGenerationSnapshotIsCurrent( connectionGeneration: UUID, revision: UInt64, @@ -560,6 +629,9 @@ final class MobileWorkspace: Identifiable { } private func resetCommandGeneratorShellState() { + terminalContext.reset() + hasRuntimeContext = false + contextHistory.removeAll() cancelCommandGeneration() integratedShell = nil remoteSystemInfo = nil @@ -583,8 +655,14 @@ final class MobileWorkspace: Identifiable { resetCommandGeneratorShellState() return } + if case .runtime(let update) = event { + receiveRuntimeContext(update) + return + } + guard !hasRuntimeContext else { return } commandMonitor.receiveShellEvent(event) switch event { + case .runtime: break case .available(let shell, let systemInfo, _): integratedShell = shell remoteSystemInfo = systemInfo @@ -641,14 +719,68 @@ final class MobileWorkspace: Identifiable { } } + private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) { + hasRuntimeContext = true + let previous = terminalContext.revision + let events = terminalContext.receive(update, generation: terminalContext.generation) + commandMonitor.receiveRuntimeEvents(events) + if previous != terminalContext.revision { + commandDraft.cancel() + cancelPendingUpload() + invalidateCommandGeneration() + } + workingDirectory = terminalContext.directory + integratedShell = terminalContext.active?.shell + shellIntegrationAvailable = terminalContext.active?.capabilities.contains(.commandLifecycle) == true + isShellPromptReady = terminalContext.active?.promptReady == true + for event in events { + switch event.payload { + case .commandStarted(let command): + if command == commandExcludedFromHistory { + commandExcludedFromHistory = nil + } else { + contextHistory[event.context] = commandHistory.record(command)?.id + } + case .commandFinished(let status, let duration): + if let id = contextHistory.removeValue(forKey: event.context) { + commandHistory.recordCompletion(for: id, exitStatus: status, duration: duration) + } + case .draft(_, let value): + if terminalContext.active?.identity == event.context { commandDraft.receive(value) } + default: break + } + } + if update.status == .ordinary, isShellPromptReady { + isInitialCommandSubmissionReady = true + submitPendingInitialCommandIfReady() + } + } + func connect() { guard !busy, !connected else { return } + if descriptor.kind != .ssh, sessionPersistence.requiresSelection { + busy = true + presentConnectionJourney(mode: .connection) + attempt = Task { [weak self] in + guard let self else { return } + do { + try await sessionPersistence.confirmPlainConnection() + busy = false + connect() + } catch { + busy = false + status = String(localized: AppStrings.Connections.disconnected) + } + } + return + } cancelPendingUpload() stopSSHEventConsumer() let reconnecting = connection.isRecovering let token = UUID() generation = token commandMonitor.beginConnection() + if descriptor.kind == .ssh { commandMonitor.requireScopedEvents() } activeHistoryID = nil resetCommandGeneratorShellState() operationError = nil @@ -790,6 +922,31 @@ final class MobileWorkspace: Identifiable { let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list)) backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8)) } + let persistence = sessionPersistence + let explicit = pendingCommand?.command + let startup = explicit ?? pendingConnectionStartupCommand?.command + let integration = isShellIntegrationEnabled + if host.sessionPersistence.enabled || persistence.attachment != nil || persistence.requiresSelection + { + persistence.didChangeAttachment = { [weak self] reference in + guard let self, self.generation == token else { return } + if reference != nil { + self.pendingCommand = nil + self.pendingConnectionStartupCommand = nil + } + self.activitySession.recovery?.remoteSession = reference.flatMap { + try? JSONEncoder().encode($0) + } + self.didChangeState?(self.activitySession) + } + await session.configureStartup { execute in + try await persistence.prepare( + preferences: host.sessionPersistence, registry: .live, execute: execute, + hostID: host.id, endpoint: "\(host.host):\(host.sshPort)", username: config.username, + startupCommand: startup, requiresNewSession: explicit != nil, + shellIntegration: integration, environment: host.environmentVariables) + } + } try await connectionServices.connectSSH( session, request, @@ -878,13 +1035,17 @@ final class MobileWorkspace: Identifiable { proxySubmissions.removeAll() let wasReconnecting = connection.markConnected() connected = true - if let ssh { + if let ssh, sessionPersistence.attachment == nil { commandHistory.load( using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil) } status = String(localized: AppStrings.Connections.connected) journey.succeed() - if wasReconnecting, descriptor.kind == .ssh { + if sessionPersistence.attachment != nil { + pendingCommand = nil + pendingConnectionStartupCommand = nil + } + if wasReconnecting, descriptor.kind == .ssh, sessionPersistence.attachment == nil { appendTerminalNotice(String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted)) } if descriptor.kind == .ssh { @@ -902,7 +1063,7 @@ final class MobileWorkspace: Identifiable { stopSSHEventConsumer() connectionFailure = error is CancellationError - ? String(localized: AppStrings.Connections.authenticationCancelled) : error.localizedDescription + ? String(localized: AppStrings.Connections.connectionCancelled) : error.localizedDescription let managedSFTPFailure = host.isTailscaleManaged && descriptor.kind == .sftp && !(error is CancellationError) @@ -1175,7 +1336,75 @@ final class MobileWorkspace: Identifiable { connect() } + func restoreRemoteSession(_ reference: RemoteSessionReference?) { + sessionPersistence.attachment = reference + activitySession.recovery?.remoteSession = reference.flatMap { try? JSONEncoder().encode($0) } + didChangeState?(activitySession) + } + + func terminatePersistentSession( + _ reference: RemoteSessionReference, isCurrentOwner: @MainActor () -> Bool = { true } + ) async throws { + guard sessionPersistence.attachment == reference, let channel = ssh else { + throw RemoteSessionFailure.sessionMissing + } + try await channel.releaseTerminalForControl() + connected = false + resetCommandGeneratorShellState() + status = String(localized: AppStrings.Connections.disconnected) + let execute = try await channel.sessionCommandExecutor() + let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute) + let sessions = try await backend.sessions() + guard isCurrentOwner(), ssh === channel, sessionPersistence.attachment == reference else { + throw CancellationError() + } + guard sessions.contains(where: { backend.matches(reference, session: $0) }) else { + throw RemoteSessionFailure.ownershipMismatch + } + try await backend.terminate(reference) + if sessionPersistence.attachment == reference { sessionPersistence.markEnded() } + connected = false + status = String(localized: AppStrings.SessionPersistence.ended) + } + private func handleRemoteExit(_ result: Result) { + cancelPendingUpload() + resetCommandGeneratorShellState() + if case .success(let exitCode) = result, let reference = sessionPersistence.attachment, let channel = ssh { + let attemptID = generation + Task { [weak self] in + guard let self else { return } + var sessionExists: Bool? + do { + let execute = try await channel.sessionCommandExecutor() + let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute) + let sessions = try await backend.sessions() + sessionExists = sessions.contains(where: { backend.matches(reference, session: $0) }) + } catch { + // An unverified exit must not erase identity or start a replacement session. + } + guard self.generation == attemptID, self.sessionPersistence.attachment == reference else { return } + let shouldClose = self.sessionPersistence.finishExit(exitCode: exitCode, sessionExists: sessionExists) + await channel.disconnect() + guard self.generation == attemptID else { return } + self.ssh = nil + self.connection.resetRecovery() + self.connected = false + self.busy = false + self.connectionResumeIntent = .none + if shouldClose { + self.didRequestClose?(self.id) + return + } + self.status = String( + localized: exitCode != 0 + ? AppStrings.SessionPersistence.failed + : self.sessionPersistence.detached + ? AppStrings.SessionPersistence.detached : AppStrings.SessionPersistence.ended) + } + return + } + cancelPendingUpload() stopSSHEventConsumer() resetCommandGeneratorShellState() @@ -1274,6 +1503,7 @@ final class MobileWorkspace: Identifiable { var connectionOrigin: RemoteConnectionOrigin = .library var externalSFTPResourcePath: String? = nil var externalSFTPMustBeDirectory = false + var remoteSession: RemoteSessionReference? = nil } var reopenState: ReopenState { @@ -1281,10 +1511,11 @@ final class MobileWorkspace: Identifiable { descriptor: descriptor, transientHost: transientHost, connectionRoute: connectionRoute, usernameHint: usernameHint, connectionOrigin: connectionOrigin, externalSFTPResourcePath: externalSFTPResourcePath, - externalSFTPMustBeDirectory: externalSFTPMustBeDirectory) + externalSFTPMustBeDirectory: externalSFTPMustBeDirectory, remoteSession: sessionPersistence.attachment) } func disconnect() { + sessionPersistence.cancel() commandHistory.cancelLoading() commandMonitor.endConnection() cancelPendingUpload() @@ -1404,9 +1635,15 @@ 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: descriptor.kind == .ssh ? .sshSession : .sftpChannel, + purpose: purpose, username: hosts.identity(for: host)?.username, mode: mode) } @@ -1429,6 +1666,7 @@ final class MobileWorkspace: Identifiable { let connectionGeneration = generation let preparationID = UUID() + let contextTicket = terminalContext.ticket(requiring: .directory) uploadPreparationID = preparationID uploadNotice = .preparing(destination) uploadPreparationTask = Task { [weak self] in @@ -1442,6 +1680,7 @@ final class MobileWorkspace: Identifiable { var client: (any SFTPClientProtocol)? var ownsClient = false do { + try await confirmDirectoryContext(contextTicket) if descriptor.kind == .sftp { guard let sftp else { throw SFTPError.notConnected } client = sftp @@ -1471,6 +1710,7 @@ final class MobileWorkspace: Identifiable { destination: destination, client: client ) + try await confirmDirectoryContext(contextTicket) try Task.checkCancellation() guard uploadPreparationID == preparationID, generation == connectionGeneration, uploadDestination == destination @@ -1497,7 +1737,7 @@ final class MobileWorkspace: Identifiable { ownsClient: ownsClient, context: context, connectionGeneration: connectionGeneration, - insertPaths: insertPaths + insertPaths: insertPaths, terminalContext: contextTicket ) if batch.conflictCount > 0 { if uploadNotice?.isRejected != true { uploadNotice = nil } @@ -1679,6 +1919,7 @@ final class MobileWorkspace: Identifiable { } } do { + try await confirmDirectoryContext(pendingUpload.terminalContext) let result = try await SFTPLocalUploadPlanner.enqueue( pendingUpload.batch, policy: policy, @@ -1693,7 +1934,17 @@ final class MobileWorkspace: Identifiable { self.generation == pendingUpload.connectionGeneration else { return } let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " " - _ = self.terminal.paste(text: text) + if self.hasRuntimeContext { + guard let ticket = pendingUpload.terminalContext, + self.terminalContext.isCurrent(ticket, requiring: .directory) + else { return } + Task { + guard self.terminalContext.isCurrent(ticket, requiring: .directory) else { return } + await self.replaceCurrentDraft(with: text) + } + } else { + _ = self.terminal.paste(text: text) + } } : nil ) { [weak self] state in guard state == .completed else { return } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 9cfc3c17..04fda073 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -761,18 +761,25 @@ struct MobileWorkspaceView: View { switch launch.kind { case .ssh: Button(AppStrings.Connections.openWithSsh.swiftUIKey, systemImage: "terminal") { - openSameDirectory(launch) + openVerifiedDirectory(launch) } case .telnet: EmptyView() case .sftp: - Button(AppStrings.SFTP.openWithSftp.swiftUIKey, systemImage: "folder") { openSameDirectory(launch) } + Button(AppStrings.SFTP.openWithSftp.swiftUIKey, systemImage: "folder") { openVerifiedDirectory(launch) } } } } + private func openVerifiedDirectory(_ launch: MobileWorkspaceDirectoryLaunch) { + Task { + if let verified = await workspace.validatedSameDirectoryLaunch(launch) { openSameDirectory(verified) } + } + } + private var showsJourney: Bool { workspace.journey.isPresented || workspace.authenticationRequired || workspace.hostKeyPrompt != nil + || workspace.sessionPersistence.prompt != nil } private var isSFTP: Bool { workspace.descriptor.kind == .sftp } @@ -794,7 +801,8 @@ struct MobileWorkspaceView: View { retryReconnectNow: { workspace.retryReconnectNow() }, stopReconnecting: { workspace.stopReconnecting() }, cancel: { closeWorkspace(workspace.id) }, securityKeySigning: workspace.securityKeySigning, - keyboardAuthentication: workspace.keyboardAuthentication) + keyboardAuthentication: workspace.keyboardAuthentication, + remoteSession: workspace.sessionPersistence) } } diff --git a/TFI/Infrastructure/MobileWorkspaceStore.swift b/TFI/Infrastructure/MobileWorkspaceStore.swift index b4052dd3..f524e899 100644 --- a/TFI/Infrastructure/MobileWorkspaceStore.swift +++ b/TFI/Infrastructure/MobileWorkspaceStore.swift @@ -1,79 +1,13 @@ import Foundation -import TermindDomain -import TermindInfrastructure -/// Device-local connection intent only. Credentials, commands and live session state never enter this store. -/// One app-owned store writes the complete snapshot, so saves from different windows cannot overwrite each other. +/// Retire local workspace snapshots without migrating them into a new cache. @MainActor -final class MobileWorkspaceStore { +enum MobileWorkspaceStore { static let key = "mobile.workspaces.v1" + static let recentlyClosedKey = "mobile.recentlyClosed.v2" - struct Snapshot: Codable, Equatable { - var version = 2 - var sessions: [String: [MobileWorkspaceDescriptor]] = [:] - /// Legacy records have no owner until the first native scene session binds. - var unassigned: [MobileWorkspaceDescriptor] = [] - } - - private struct Version: Decodable { var version: Int } - private struct VersionOne: Decodable { - var version: Int - var workspaces: [MobileWorkspaceDescriptor] - } - - private enum StoreError: Error { case invalidSnapshot } - let recentlyClosed: RecentlyClosedStore> - private let defaults: UserDefaults? - private var canSave = true - - init(defaults: UserDefaults?) { - self.defaults = defaults - recentlyClosed = RecentlyClosedStore(defaults: defaults, key: "mobile.recentlyClosed.v2") - } - - func load() throws -> Snapshot { - do { - guard let value = defaults?.object(forKey: Self.key) else { - canSave = true - return Snapshot() - } - guard let data = value as? Data else { throw StoreError.invalidSnapshot } - let decoder = JSONDecoder() - let snapshot: Snapshot - if let version = try? decoder.decode(Version.self, from: data) { - switch version.version { - case 1: - snapshot = Snapshot(unassigned: try decoder.decode(VersionOne.self, from: data).workspaces) - case 2: - snapshot = try decoder.decode(Snapshot.self, from: data) - default: - throw StoreError.invalidSnapshot - } - } else { - snapshot = Snapshot(unassigned: try decoder.decode([MobileWorkspaceDescriptor].self, from: data)) - } - try validate(snapshot) - canSave = true - // Migration is committed together with the first window's ownership, never during a read. - return snapshot - } catch { - canSave = false - throw error - } - } - - func save(_ snapshot: Snapshot) throws { - guard canSave else { throw StoreError.invalidSnapshot } - try validate(snapshot) - let data = try JSONEncoder().encode(snapshot) - defaults?.set(data, forKey: Self.key) - } - - private func validate(_ snapshot: Snapshot) throws { - let descriptors = snapshot.unassigned + snapshot.sessions.values.flatMap { $0 } - guard snapshot.version == 2, - snapshot.sessions.keys.allSatisfy({ !$0.isEmpty }), - Set(descriptors.map(\.id)).count == descriptors.count - else { throw StoreError.invalidSnapshot } + static func retire(in defaults: UserDefaults) { + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: recentlyClosedKey) } } diff --git a/TFI/Platform/MobileLiveActivityController.swift b/TFI/Platform/MobileLiveActivityController.swift index 4909e02f..a5c1fca6 100644 --- a/TFI/Platform/MobileLiveActivityController.swift +++ b/TFI/Platform/MobileLiveActivityController.swift @@ -12,6 +12,8 @@ final class MobileLiveActivityController { private let services: MobileLiveActivityServices private var focusedWorkspaceID: UUID? private var pendingStart = false + private var retainedSessions: [WorkspaceActivityAttributes.Session] = [] + private var removedWorkspaceIDs: Set = [] init(enabled: Bool = true, services: MobileLiveActivityServices = .live) { self.enabled = enabled @@ -20,6 +22,12 @@ final class MobileLiveActivityController { let restored = services.activities() activity = restored.first { $0.state == .active || $0.state == .stale } focusedWorkspaceID = activity?.focusedWorkspaceID + retainedSessions = (activity?.contentState?.sessions ?? []).compactMap { session in + guard session.recovery?.version == 1 else { return nil } + var disconnected = session + disconnected.connected = false + return disconnected + } let retainedID = activity?.id updates = Task { for duplicate in restored where duplicate.id != retainedID { @@ -29,6 +37,19 @@ final class MobileLiveActivityController { } } + func recovery(for id: UUID) -> WorkspaceActivityAttributes.Recovery? { + guard !removedWorkspaceIDs.contains(id) else { return nil } + guard activity?.state == .active || activity?.state == .stale else { return nil } + return retainedSessions.first(where: { $0.id == id })?.recovery + ?? activity?.contentState?.sessions.first(where: { $0.id == id })?.recovery + } + + func remove(_ id: UUID) { + // ActivityKit updates are asynchronous; never recover from its stale content after an explicit close. + removedWorkspaceIDs.insert(id) + retainedSessions.removeAll { $0.id == id } + } + func focus(_ id: UUID, in sessions: [WorkspaceActivityAttributes.Session]) { guard sessions.contains(where: { $0.id == id }) else { return } focusedWorkspaceID = id @@ -41,8 +62,10 @@ final class MobileLiveActivityController { private func schedule(_ sessions: [WorkspaceActivityAttributes.Session], mayStart: Bool) { guard enabled else { return } + let liveIDs = Set(sessions.map(\.id)) + retainedSessions.removeAll { liveIDs.contains($0.id) } let state = WorkspaceActivityAttributes.ContentState.snapshot( - sessions, focusedID: focusedWorkspaceID) + sessions + retainedSessions, focusedID: focusedWorkspaceID) let previous = updates updates = Task { [weak self] in await previous?.value diff --git a/TFI/Platform/MobileLiveActivityServices.swift b/TFI/Platform/MobileLiveActivityServices.swift index 9a020e07..4a42d990 100644 --- a/TFI/Platform/MobileLiveActivityServices.swift +++ b/TFI/Platform/MobileLiveActivityServices.swift @@ -7,10 +7,15 @@ protocol MobileWorkspaceActivity { var id: String { get } var state: ActivityState { get } var focusedWorkspaceID: UUID? { get } + var contentState: WorkspaceActivityAttributes.ContentState? { get } func update(_ state: WorkspaceActivityAttributes.ContentState) async func end() async } +extension MobileWorkspaceActivity { + var contentState: WorkspaceActivityAttributes.ContentState? { nil } +} + @MainActor struct MobileLiveActivityServices { var activities: () -> [any MobileWorkspaceActivity] @@ -39,6 +44,7 @@ private struct WorkspaceActivity: MobileWorkspaceActivity { var id: String { activity.id } var state: ActivityState { activity.activityState } var focusedWorkspaceID: UUID? { activity.content.state.focusedWorkspaceID } + var contentState: WorkspaceActivityAttributes.ContentState? { activity.content.state } func update(_ state: WorkspaceActivityAttributes.ContentState) async { await activity.update(ActivityContent(state: state, staleDate: Date.now.addingTimeInterval(60))) diff --git a/TFI/UI/Components/MobileConnectionJourneyView.swift b/TFI/UI/Components/MobileConnectionJourneyView.swift index 3db06df7..8985fa35 100644 --- a/TFI/UI/Components/MobileConnectionJourneyView.swift +++ b/TFI/UI/Components/MobileConnectionJourneyView.swift @@ -18,6 +18,7 @@ struct MobileConnectionJourneyView: View { var securityKeySigning: SecurityKeySigningCoordinator? = nil var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil var cancelKeyboardAuthentication: (() -> Void)? = nil + var remoteSession: RemoteSessionCoordinator? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -75,6 +76,9 @@ struct MobileConnectionJourneyView: View { SSHHostKeyConfirmationForm( prompt: hostKey, displayAddress: hostKey.observation.hostname, proceed: { accept(false) }, trust: { accept(true) }, deny: cancel) + } else if let remoteSession, remoteSession.prompt != nil { + RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel) + .connectionFormChrome(isEnabled: true) } else if case .failed(let message) = journey.phase { MobileJourneyFailureCard(message: message, retry: retry, cancel: cancel, copyLogs: copyLogs) } else if let reconnectState { diff --git a/TFITests/MobileAdaptiveLayoutTests.swift b/TFITests/MobileAdaptiveLayoutTests.swift index 1fbf4e1d..785d21e0 100644 --- a/TFITests/MobileAdaptiveLayoutTests.swift +++ b/TFITests/MobileAdaptiveLayoutTests.swift @@ -609,7 +609,7 @@ struct MobileAdaptiveLayoutTests { } @Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"]) - func recentWorkspaceListRestoresOnlySelectedRowAndNavigates(language: String) async throws { + func memoryWorkspaceListFocusesSelectedRowAndNavigates(language: String) async throws { let suite = "termind.recent-workspace-ui.\(UUID())" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } @@ -622,19 +622,17 @@ struct MobileAdaptiveLayoutTests { MobileWorkspaceDescriptor(hostID: host.id, title: "Project Files", kind: .sftp), ] for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular] { - let store = MobileWorkspaceStore(defaults: defaults) - try store.save(.init(unassigned: descriptors)) var dependencies = MobileAppDependencies.testing() dependencies.container = seed.container - dependencies.workspaceStore = store let app = try MobileAppModel(dependencies: dependencies) defer { app.testWorkspaceSession.closeAll() } + app.testWorkspaceSession.restoreClosedStates(descriptors.map { .init(descriptor: $0) }) let scene = MobileSceneModel(app: app, sessionID: "test") let historyCount = app.connectionHistory.entries().count scene.selectedDestination = .workspaces try await withRoot(app: app, scene: scene, language: language, initialSizeClass: sizeClass) { root in - #expect(app.workspaces.isEmpty) - #expect(scene.workspaceCount == 0) + #expect(app.workspaces.count == 2) + #expect(scene.workspaceCount == 2) #expect(scene.canCloseAllWorkspaces) let title = language == "en" ? "Workspaces" : language == "zh-Hans" ? "工作区" : "工作區" let navigation = try #require(navigation(titled: title, in: root)) @@ -649,8 +647,8 @@ struct MobileAdaptiveLayoutTests { scene.selectWorkspace(descriptors[1].id) try await settle() #expect(scene.workspaceID == descriptors[1].id) - #expect(app.workspaces.map(\.id) == [descriptors[1].id]) - #expect(app.testWorkspaceSession.recentWorkspaces == [descriptors[0]]) + #expect(app.workspaces.map(\.id) == descriptors.map(\.id)) + #expect(app.testWorkspaceSession.recentWorkspaces.isEmpty) #expect(app.testWorkspaceSession.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id)) #expect(list.numberOfSections == 2) #expect((0.. Bool) async -> Bool { - for _ in 0..<100 { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return condition() - } - - private final class Fixture { - let suiteName = "termind.workspace-restoration-tests.\(UUID().uuidString)" - let defaults: UserDefaults - let container: ModelContainer - let identity = Identity(name: "Deploy", username: "deploy") - let background = MobileTestBackgroundTaskManager() - let connections = MobileSFTPConnectionBox( - clients: [MobileTestSFTPClient(), MobileTestSFTPClient(), MobileTestSFTPClient()]) - - init() throws { - defaults = try #require(UserDefaults(suiteName: suiteName)) - container = try TermindPersistence.makeContainer(inMemory: true) - } - - func store() -> MobileWorkspaceStore { MobileWorkspaceStore(defaults: defaults) } - func cleanUp() { defaults.removePersistentDomain(forName: suiteName) } - - func makeApp(services: MobileWorkspaceConnectionServices? = nil) throws -> MobileAppModel { - var dependencies = MobileAppDependencies.testing( - background: background, workspaceConnections: services ?? connections.services) - dependencies.container = container - dependencies.workspaceStore = store() - return try MobileAppModel(dependencies: dependencies) - } - - func installHost(in app: MobileAppModel) throws -> TermindDomain.Host { - var host = TermindDomain.Host(name: "Server", host: "example.invalid") - host.identityID = identity.id - try installPassword(in: app) - app.library.upsert(host) - app.library.trust( - KnownHostEntry( - hostname: host.host, port: host.port, algorithm: .ed25519, - fingerprint: "SHA256:test")) - return host - } - - func installPassword(in app: MobileAppModel) throws { - try app.library.upsert(identity, passwordUpdate: .replace("test-only")) + @Test func unsupportedAndCommandBearingActivityLinksShowAnErrorWithoutRebuilding() throws { + let app = try MobileAppModel(inMemory: true) + let host = TermindDomain.Host(name: "Saved", host: "example.invalid") + app.library.upsert(host) + let scene = MobileSceneModel(app: app, sessionID: "first") + let source = UUID() + for query in [ + "v=2&host=\(host.id)&kind=ssh", + "v=1&host=\(host.id)&kind=ssh&command=ls", + "v=1&host=\(host.id)&kind=unknown", + ] { + scene.isWorkspaceUnavailable = false + scene.open(try #require(URL(string: "termind://workspace/\(source)?\(query)"))) + #expect(scene.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) } } } diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 7cd6b9d2..3aa0e481 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -94,6 +94,34 @@ private final class MobileSSHCommandConnectionBox { @Suite @MainActor struct MobileCommandGeneratorTests { + @Test func runtimePaneInvalidationClearsDirectoryAndIgnoresUnscopedEvents() async throws { + var (host, library) = try commandFixture() + host.shellIntegrationMode = .enabled + library.upsert(host) + let connections = MobileSSHCommandConnectionBox() + let workspace = MobileWorkspace( + descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), + hosts: library, connectionServices: connections.services) + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + let context = TerminalShellContext( + identity: .init(backend: "test-multiplexer", server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: "/pane-a", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + workspace.receiveShellEvent( + .runtime(.init(status: .multiplexer, active: context.identity, contexts: [context]))) + #expect(!host.sessionPersistence.enabled) + #expect(workspace.canGenerateFromCurrentDraft) + #expect(workspace.workingDirectory == "/pane-a") + workspace.receiveShellEvent(.runtime(.init(status: .unavailable))) + workspace.receiveWorkingDirectory("/stale") + workspace.receiveShellEvent(.promptReady) + #expect(workspace.workingDirectory == nil) + #expect(workspace.sameDirectoryLaunch == nil) + #expect(!workspace.canGenerateFromCurrentDraft) + } + @Test(arguments: HostShellIntegrationMode.allCases, [false, true]) func integrationUsesConnectionSnapshotUntilManualOrAutomaticReconnect( mode: HostShellIntegrationMode, global: Bool diff --git a/TFITests/MobileLiveActivityRestorationTests.swift b/TFITests/MobileLiveActivityRestorationTests.swift index 06d71a26..ade9de24 100644 --- a/TFITests/MobileLiveActivityRestorationTests.swift +++ b/TFITests/MobileLiveActivityRestorationTests.swift @@ -46,6 +46,22 @@ struct MobileLiveActivityRestorationTests { #expect(existing.updates.count == 1) } + @Test func removedRecoveryCannotBeReadBackFromStaleSystemContent() { + let backend = Backend() + let id = UUID() + let recovery = TermindIOS.WorkspaceActivityAttributes.Recovery( + sourceWorkspaceID: id, hostID: UUID(), kind: "ssh") + let card = TermindIOS.WorkspaceActivityAttributes.Session( + id: id, title: "Server", kind: "ssh", connected: false, recovery: recovery) + let existing = TestActivity() + existing.contentState = .snapshot([card], focusedID: id) + backend.existing = [existing] + let controller = MobileLiveActivityController(services: backend.services) + #expect(controller.recovery(for: id) == recovery) + controller.remove(id) + #expect(controller.recovery(for: id) == nil) + } + @Test func closeLastThenOpenWaitsForOldActivityToFinishEnding() async { let backend = Backend() let existing = TestActivity() @@ -68,126 +84,58 @@ struct MobileLiveActivityRestorationTests { #expect(backend.created.first?.ended == false) } - @Test func coldLaunchEndsOldActivityAndOnlyLoadsRecentDescriptors() async throws { - let suite = "termind.activity-restoration-tests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + @Test func coldLaunchRetainsRecoverableSystemActivityWithoutConnecting() async throws { let seed = try MobileAppModel(inMemory: true) let host = TermindDomain.Host(name: "Server", host: "example.invalid") seed.library.upsert(host) - let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Saved", kind: .ssh) - try MobileWorkspaceStore(defaults: defaults).save(.init(unassigned: [descriptor])) + let oldID = UUID() + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$42", name: "External", identity: Data("backend-identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "u", ownershipToken: nil) + let card = TermindIOS.WorkspaceActivityAttributes.Session( + id: oldID, title: "Saved", kind: "ssh", connected: true, + recovery: .init( + sourceWorkspaceID: oldID, hostID: host.id, kind: "ssh", + remoteSession: try JSONEncoder().encode(reference))) let backend = Backend() - let existing = TestActivity(focusedWorkspaceID: descriptor.id) + let existing = TestActivity(focusedWorkspaceID: oldID) + existing.contentState = .snapshot([card], focusedID: oldID) backend.existing = [existing] var dependencies = MobileAppDependencies.testing() dependencies.container = seed.container - dependencies.workspaceStore = MobileWorkspaceStore(defaults: defaults) dependencies.liveActivities = MobileLiveActivityController(services: backend.services) let app = try MobileAppModel(dependencies: dependencies) app.sceneChanged(UUID(), phase: .active) - - #expect(await eventually { existing.ended }) - #expect(existing.updates.isEmpty) - #expect(backend.requests.isEmpty) + #expect(await eventually { !existing.updates.isEmpty }) + #expect(!existing.ended) + #expect(existing.updates.last?.connectedCount == 0) #expect(app.workspaces.isEmpty) - #expect(app.testWorkspaceSession.recentWorkspaces == [descriptor]) - app.testWorkspaceSession.closeAll() + #expect(backend.requests.isEmpty) + let scene = MobileSceneModel(app: app, sessionID: "new") + scene.open(card.destinationURL) + let rebuilt = try #require(app.workspaces.first) + #expect(rebuilt.id != oldID) + #expect(rebuilt.sessionPersistence.requiresSelection) + #expect(rebuilt.sessionPersistence.attachment == reference) + scene.workspaceSession.closeAll() #expect(await eventually { existing.ended }) + app.sceneChanged(scene.id, phase: .active) + #expect(backend.requests.isEmpty) } - @Test func coldLaunchWithoutSystemActivityDoesNotCreateOneUntilNewWorkspaceOpens() async throws { - let suite = "termind.activity-restoration-tests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let seed = try MobileAppModel(inMemory: true) - let host = TermindDomain.Host(name: "Server", host: "example.invalid") - seed.library.upsert(host) - let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Saved", kind: .ssh) - try MobileWorkspaceStore(defaults: defaults).save(.init(unassigned: [descriptor])) - let backend = Backend() - var dependencies = MobileAppDependencies.testing() - dependencies.container = seed.container - dependencies.workspaceStore = MobileWorkspaceStore(defaults: defaults) - dependencies.liveActivities = MobileLiveActivityController(services: backend.services) - let app = try MobileAppModel(dependencies: dependencies) - let sceneID = UUID() - app.sceneChanged(sceneID, phase: .active) - app.sceneChanged(sceneID, phase: .background) - app.sceneChanged(sceneID, phase: .active) - _ = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh)) - #expect(await eventually { backend.requests.count == 1 }) - #expect(backend.requests.map(\.workspaceCount) == [1]) - app.testWorkspaceSession.close(app.workspaces[0].id) - #expect(app.testWorkspaceSession.recentWorkspaces == [descriptor]) - #expect(await eventually { backend.created.first?.ended == true }) - app.testWorkspaceSession.closeAll() - } - - @Test func emptyColdLaunchEndsOrphanedActivity() async throws { + @Test func coldLaunchWithoutRecoveryEndsUnusableCard() async throws { let backend = Backend() let existing = TestActivity() backend.existing = [existing] var dependencies = MobileAppDependencies.testing() dependencies.liveActivities = MobileLiveActivityController(services: backend.services) let app = try MobileAppModel(dependencies: dependencies) - #expect(app.workspaces.isEmpty) + app.sceneChanged(UUID(), phase: .active) #expect(await eventually { existing.ended }) + #expect(app.workspaces.isEmpty) #expect(backend.requests.isEmpty) } - @Test(arguments: [false, true]) - func oldLinkRestoresBeforeOrAfterForegroundAndDoesNotResurrectDismissedCard(linkFirst: Bool) async throws { - let suite = "termind.activity-link-tests.\(UUID())" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let seed = try MobileAppModel(inMemory: true) - let host = TermindDomain.Host(name: "Server", host: "example.invalid") - seed.library.upsert(host) - let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Saved", kind: .ssh) - try MobileWorkspaceStore(defaults: defaults).save(.init(unassigned: [descriptor])) - let backend = Backend() - backend.canStart = false - let old = TestActivity(focusedWorkspaceID: descriptor.id) - backend.existing = [old] - var dependencies = MobileAppDependencies.testing() - dependencies.container = seed.container - dependencies.workspaceStore = MobileWorkspaceStore(defaults: defaults) - dependencies.liveActivities = MobileLiveActivityController(services: backend.services) - let app = try MobileAppModel(dependencies: dependencies) - defer { app.testWorkspaceSession.closeAll() } - let scene = MobileSceneModel(app: app, sessionID: "test") - #expect(await eventually { old.ended }) - if linkFirst { - scene.open(descriptor.destinationURL) - scene.activatePendingWorkspace() - // Drain the serial queue while requests are disallowed. - for _ in 0..<20 { await Task.yield() } - #expect(backend.requests.isEmpty) - } - backend.canStart = true - app.sceneChanged(scene.id, phase: .active) - if !linkFirst { - scene.open(descriptor.destinationURL) - scene.activatePendingWorkspace() - } - #expect(await eventually { backend.requests.count == 1 }) - #expect(backend.requests.first?.sessions.map(\.id) == [descriptor.id]) - #expect(scene.workspaceID == descriptor.id) - #expect(app.connectionHistory.entries().count == 1) - let created = try #require(backend.created.first) - created.state = .dismissed - app.sceneChanged(scene.id, phase: .background) - app.sceneChanged(scene.id, phase: .active) - // A barrier proves earlier foreground updates have run without creating a second activity. - app.testWorkspaceSession.closeAll() - let next = session() - app.liveActivities.start([next]) - #expect(await eventually { backend.requests.count == 2 }) - #expect(backend.requests.last?.sessions.map(\.id) == [next.id]) - app.liveActivities.update([]) - } - @Test func closingBeforeForegroundCancelsPendingStart() async { let backend = Backend() backend.canStart = false @@ -238,6 +186,7 @@ struct MobileLiveActivityRestorationTests { var state: ActivityState let focusedWorkspaceID: UUID? var updates: [TermindIOS.WorkspaceActivityAttributes.ContentState] = [] + var contentState: TermindIOS.WorkspaceActivityAttributes.ContentState? var ended = false var suspendsEnd = false var endContinuation: CheckedContinuation? @@ -248,6 +197,7 @@ struct MobileLiveActivityRestorationTests { } func update(_ state: TermindIOS.WorkspaceActivityAttributes.ContentState) async { + contentState = state updates.append(state) } diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 7ede86e6..1c8cabfd 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -13,35 +13,19 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileWorkspaceSessionTests { - @Test func closedRecordsAcrossLaunchesShareOneListAndAreConsumedAcrossWindows() throws { + @Test func recentlyClosedIsSharedOnlyWithinOneProcess() throws { let fixture = try Fixture() defer { fixture.cleanUp() } - let firstApp = try fixture.makeApp() - let owner = MobileSceneModel(app: firstApp, sessionID: "first") - owner.open(hostID: fixture.host.id, kind: .sftp) - owner.activatePendingWorkspace() - let closed = try #require(owner.activeWorkspace) - owner.closeActiveWorkspace() - - let nextApp = try fixture.makeApp() - let window = MobileSceneModel(app: nextApp, sessionID: "second") - let other = MobileSceneModel(app: nextApp, sessionID: "other") - #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [closed.id]) - window.open(hostID: fixture.host.id, kind: .ssh) - window.activatePendingWorkspace() - let current = try #require(window.activeWorkspace) - window.closeActiveWorkspace() - #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [current.id, closed.id]) - window.reopenLastClosedWorkspace() - #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [closed.id]) - window.reopenLastClosedWorkspace() - window.activatePendingWorkspace() - #expect(window.activeWorkspace?.descriptor.kind == .sftp) - #expect(window.activeWorkspace !== closed) - #expect(!other.workspaceSession.canReopen) - #expect(!window.workspaceSession.canReopen) - let thirdApp = try fixture.makeApp() - #expect(thirdApp.closedWorkspaceRecords.isEmpty) + let app = try fixture.makeApp() + let first = app.workspaceSession(for: "first") + let other = app.workspaceSession(for: "other") + let id = try #require(first.open(hostID: fixture.host.id, kind: .ssh)) + first.close(id) + #expect(other.canReopen) + #expect(other.reopenLastClosed() == id) + #expect(!first.canReopen) + other.close(id) + #expect(try fixture.makeApp().closedWorkspaceRecords.isEmpty) } @Test func discardedWindowsAreGroupedAndReopenedInANewSession() throws { @@ -98,9 +82,9 @@ struct MobileWorkspaceSessionTests { let records = (0..<3).map { MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) } - fixture.defaults.set(try JSONEncoder().encode(records), forKey: MobileWorkspaceStore.key) let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") + session.restoreClosedStates(records.map { .init(descriptor: $0) }) session.closeAll() #expect(session.recentlyClosedRecords.flatMap(\.items).map(\.id) == records.reversed().map(\.id)) #expect(session.reopenClosedWorkspace(records[0].id) == records[0].id) @@ -126,7 +110,7 @@ struct MobileWorkspaceSessionTests { owner.closeActiveWorkspace() #expect(owner.workspaceSession.canReopen) #expect(other.workspaceSession.canReopen) - #expect(try fixture.store.load().sessions["owner", default: []].isEmpty) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) owner.openSettings() owner.reopenLastClosedWorkspace() owner.activatePendingWorkspace() @@ -139,7 +123,7 @@ struct MobileWorkspaceSessionTests { #expect(reopened.sftpHistory.entries().first?.path == "/srv") #expect(other.activeWorkspace === otherWorkspace) #expect(!owner.workspaceSession.canReopen) - #expect(try fixture.store.load().sessions["owner"] == [reopened.descriptor]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) owner.reopenLastClosedWorkspace() #expect(owner.workspaceSession.workspaces.count == 1) } @@ -150,10 +134,9 @@ struct MobileWorkspaceSessionTests { let records = (0..<3).map { MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) } - fixture.defaults.set(try JSONEncoder().encode(records), forKey: MobileWorkspaceStore.key) let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") - #expect(session.workspaces.isEmpty) + session.restoreClosedStates(records.map { .init(descriptor: $0) }) session.closeOtherWorkspaces(than: records[0].id) #expect(session.reopenLastClosed() == records[2].id) @@ -240,7 +223,8 @@ struct MobileWorkspaceSessionTests { session.close(id) let nextApp = try fixture.makeApp() - #expect(nextApp.recentlyClosedStore.entries.flatMap(\.items).map(\.id) == [id]) + #expect(nextApp.recentlyClosedStore.entries.isEmpty) + #expect(firstApp.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [id]) #expect(nextApp.deleteHost(fixture.host.id)) #expect(nextApp.recentlyClosedStore.entries.isEmpty) let thirdApp = try fixture.makeApp() @@ -727,60 +711,25 @@ struct MobileWorkspaceSessionTests { let raw = Data((versioned ? "{\"version\":1,\"workspaces\":\(array)}" : array).utf8) fixture.defaults.set(raw, forKey: MobileWorkspaceStore.key) let app = try fixture.makeApp() - #expect(fixture.defaults.data(forKey: MobileWorkspaceStore.key) == raw) - let first = app.workspaceSession(for: "a") - let second = app.workspaceSession(for: "b") - #expect(first.savedWorkspaceDescriptors == records) - #expect(second.orderedWorkspaceItems.isEmpty) - #expect(app.workspaceSession(for: "a") === first) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) + #expect(app.workspaceSession(for: "a").orderedWorkspaceItems.isEmpty) + #expect(app.workspaceSession(for: "b").orderedWorkspaceItems.isEmpty) #expect(app.workspaces.isEmpty) #expect(app.connectionHistory.entries().isEmpty) - let saved = try fixture.store.load() - #expect(saved.version == 2) - #expect(saved.unassigned.isEmpty) - #expect(saved.sessions == ["a": records]) } - @Test func coldLaunchConsolidatesAllWindowsBeforeRoutingAnActivityLink() throws { + @Test func coldLaunchDoesNotClaimOldNativeSceneWorkspaces() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let original = try fixture.makeApp() - let a = original.workspaceSession(for: "old-a") - let b = original.workspaceSession(for: "old-b") - let firstID = try #require(a.open(hostID: fixture.host.id, kind: .ssh)) - let secondID = try #require(b.open(hostID: fixture.host.id, kind: .sftp)) - #expect(a.renameWorkspace(firstID, to: "First")) - let historyCount = original.connectionHistory.entries().count - for workspace in original.workspaces { workspace.disconnect() } + let id = try #require(original.workspaceSession(for: "old").open(hostID: fixture.host.id, kind: .ssh)) let app = try fixture.makeApp() - // Old native sessions can be discarded while the new process is starting. - app.discardWorkspaceSessions(["old-a", "old-b"]) - let first = MobileSceneModel(app: app, sessionID: "new-a") - let second = MobileSceneModel(app: app, sessionID: "new-b") - #expect(first.workspaceSession.savedWorkspaceDescriptors.map(\.id) == [firstID, secondID]) - #expect(first.workspaceSession.savedWorkspaceDescriptors.first?.title == "First") - #expect(second.workspaceSession.orderedWorkspaceItems.isEmpty) + app.discardWorkspaceSessions(["old"]) + let scene = MobileSceneModel(app: app, sessionID: "new") + scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) + #expect(scene.isWorkspaceUnavailable) #expect(app.workspaces.isEmpty) - #expect(app.connectionHistory.entries().count == historyCount) - var requests: [String] = [] - let delegate = MobileSceneDelegate() - app.activateWorkspaceSession = { owner, workspaceID, _ in - requests.append(owner) - delegate.receiveWorkspaceActivity(MobileWorkspaceSceneRouting.activity(for: workspaceID)) - } - second.open(WorkspaceActivityAttributes.destinationURL(for: secondID)) - #expect(requests == ["new-a"]) - #expect(app.workspaces.isEmpty) - let pending = try #require(delegate.consumePendingWorkspaceID()) - first.open(WorkspaceActivityAttributes.destinationURL(for: pending)) - first.activatePendingWorkspace() - let live = try #require(first.activeWorkspace) - first.open(WorkspaceActivityAttributes.destinationURL(for: pending)) - #expect(first.activeWorkspace === live) - #expect(second.workspaceSession.workspaces.isEmpty) - #expect(delegate.consumePendingWorkspaceID() == nil) - #expect(app.connectionHistory.entries().count == historyCount + 1) - #expect(try fixture.store.load().sessions.keys.sorted() == ["new-a"]) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test func missingNativeSessionHandsOffLiveWorkspacesWithoutReconnecting() throws { @@ -801,8 +750,8 @@ struct MobileWorkspaceSessionTests { #expect(second.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(app.connectionHistory.entries() == history) #expect(app.workspaceOwner(original.id) == "a") - #expect(try fixture.store.load().sessions["a"]?.count == 2) - #expect(try fixture.store.load().sessions["b"] == nil) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) // A late user-close callback must still remove only the workspaces from that window. app.discardWorkspaceSessions(["b"]) #expect(first.workspaceSession.workspaces.count == 1) @@ -835,7 +784,7 @@ struct MobileWorkspaceSessionTests { #expect(activatedOwners == ["b"]) let restored = MobileSceneModel(app: app, sessionID: "b") #expect(restored.workspaceSession === second.workspaceSession) - #expect(try fixture.store.load().sessions.keys.sorted() == ["a", "b"]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) } @Test func missingLastWindowWaitsForTheNextActiveWindow() throws { @@ -848,7 +797,7 @@ struct MobileWorkspaceSessionTests { app.sceneChanged(first.id, phase: .background) app.disconnectWorkspaceScene("a") app.reconcileWorkspaceSessions(openSessionIDs: [], activeSessionID: nil) - #expect(try fixture.store.load().sessions["a"]?.map(\.id) == [original.id]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) let next = MobileSceneModel(app: app, sessionID: "b") #expect(next.workspaceSession.workspaces.isEmpty) app.sceneChanged(next.id, phase: .active) @@ -876,7 +825,7 @@ struct MobileWorkspaceSessionTests { delegate.discardSessions(["a"]) #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) #expect(second.workspaceSession.workspaces.count == 1) - #expect(try fixture.store.load().sessions["a"] == nil) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) first.open(WorkspaceActivityAttributes.destinationURL(for: id)) #expect(first.isWorkspaceUnavailable) } @@ -899,16 +848,16 @@ struct MobileWorkspaceSessionTests { #expect(requestedSessions == ["a"]) #expect(first.workspaceSession.workspaces.map(\.id) == [firstID]) - #expect(try fixture.store.load().sessions["a"]?.map(\.id) == [firstID]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) let delegate = MobileAppDelegate() delegate.app = app delegate.discardSessions(["a"]) #expect(first.workspaceSession.isDiscarded) #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) - #expect(try fixture.store.load().sessions["a"] == nil) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(second.workspaceSession.workspaces.map(\.id) == [secondID]) - #expect(try fixture.store.load().sessions["b"]?.map(\.id) == [secondID]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(app.error == nil) first.closeSelectionOrWindow() #expect(requestedSessions == ["a"]) @@ -962,7 +911,7 @@ struct MobileWorkspaceSessionTests { #expect(requestedSessions == ["background", "second", "first"]) #expect(first.workspaceSession.isDiscarded) #expect(second.workspaceSession.workspaces.first === retainedWorkspace) - #expect(try fixture.store.load().sessions["second"]?.count == 1) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(!second.workspaceSession.isDiscarded) #expect(app.error == nil) } @@ -984,7 +933,7 @@ struct MobileWorkspaceSessionTests { #expect(scene.workspaceSession.workspaces.first === workspace) #expect(!scene.workspaceSession.isDiscarded) - #expect(try fixture.store.load().sessions["a"]?.map(\.id) == [workspace.id]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(scene.selectedDestination == .settings) #expect(!scene.isWorkspaceUnavailable) #expect(app.error == nil) @@ -1004,7 +953,7 @@ struct MobileWorkspaceSessionTests { #expect(failure as? MobileWorkspaceSceneRouting.CloseError == .sessionNotFound) #expect(scene.workspaceSession.workspaces.map(\.id) == [workspaceID]) - #expect(try fixture.store.load().sessions[id]?.map(\.id) == [workspaceID]) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(app.error == nil) } @@ -1030,7 +979,7 @@ struct MobileWorkspaceSessionTests { for id in ["a", "b"] { _ = app.workspaceSession(for: id).open(hostID: fixture.host.id, kind: .ssh) } #expect(app.deleteHost(fixture.host.id)) #expect(app.workspaces.isEmpty) - #expect(try fixture.store.load().sessions.values.allSatisfy(\.isEmpty)) + #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) } private final class Background: MobileBackgroundTaskManaging { @@ -1052,7 +1001,6 @@ struct MobileWorkspaceSessionTests { let container: ModelContainer let host = Host(name: "Server", host: "test.invalid") var apps: [MobileAppModel] = [] - var store: MobileWorkspaceStore { MobileWorkspaceStore(defaults: defaults) } init() throws { defaults = try #require(UserDefaults(suiteName: name)) @@ -1067,7 +1015,7 @@ struct MobileWorkspaceSessionTests { func makeApp() throws -> MobileAppModel { var dependencies = MobileAppDependencies.testing(background: Background()) dependencies.container = container - dependencies.workspaceStore = store + dependencies.defaults = defaults let app = try MobileAppModel(dependencies: dependencies) apps.append(app) return app diff --git a/TFITests/MobileWorkspaceStoreTests.swift b/TFITests/MobileWorkspaceStoreTests.swift index 32162b63..4df083e5 100644 --- a/TFITests/MobileWorkspaceStoreTests.swift +++ b/TFITests/MobileWorkspaceStoreTests.swift @@ -5,124 +5,48 @@ import Testing @Suite @MainActor struct MobileWorkspaceStoreTests { - @Test func legacyReadPreservesBytesUntilOwnershipIsSaved() throws { - try withStore("[]") { store, defaults in - #expect(try store.load().unassigned.isEmpty) - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == Data("[]".utf8)) - let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "New", kind: .ssh) - let snapshot = MobileWorkspaceStore.Snapshot(sessions: ["first": [descriptor]]) - try store.save(snapshot) - #expect(try MobileWorkspaceStore(defaults: defaults).load() == snapshot) - } + @Test(arguments: [Data("broken".utf8), Data("[]".utf8), Data("{\"version\":2,\"sessions\":{}}".utf8)]) + func retirementDeletesLegacySnapshotsWithoutTouchingOtherSettings(data: Data) throws { + let name = "WorkspaceRetirement.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + defaults.set(data, forKey: MobileWorkspaceStore.key) + defaults.set(data, forKey: MobileWorkspaceStore.recentlyClosedKey) + defaults.set(data, forKey: "connection-history") + MobileWorkspaceStore.retire(in: defaults) + MobileWorkspaceStore.retire(in: defaults) + #expect(defaults.object(forKey: MobileWorkspaceStore.key) == nil) + #expect(defaults.object(forKey: MobileWorkspaceStore.recentlyClosedKey) == nil) + #expect(defaults.data(forKey: "connection-history") == data) } - @Test func legacyRecordsKeepIDsTitlesAndKindsWhileDiscardingIndices() throws { - let host = UUID() - let other = UUID() - let ids = (0..<5).map { _ in UUID() } - let json = """ - [ - {"id":"\(ids[0])","hostID":"\(host)","title":"Custom","kind":"ssh"}, - {"id":"\(ids[1])","hostID":"\(host)","title":"Explicit","kind":"ssh","duplicateIndex":0}, - {"id":"\(ids[2])","hostID":"\(host)","title":"Third","kind":"ssh"}, - {"id":"\(ids[3])","hostID":"\(host)","title":"Files","kind":"sftp"}, - {"id":"\(ids[4])","hostID":"\(other)","title":"Other","kind":"ssh"} - ] - """ - try withStore(json) { store, defaults in - let descriptors = try store.load().unassigned - #expect(descriptors.map(\.id) == ids) - #expect(descriptors.map(\.title) == ["Custom", "Explicit", "Third", "Files", "Other"]) - #expect(descriptors.map(\.kind) == [.ssh, .ssh, .ssh, .sftp, .ssh]) - let migrated = defaults.data(forKey: MobileWorkspaceStore.key) - #expect(migrated == Data(json.utf8)) - #expect(try MobileWorkspaceStore(defaults: defaults).load().unassigned == descriptors) - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == migrated) - defaults.set(Data(json.utf8), forKey: MobileWorkspaceStore.key) - #expect(try store.load().unassigned == descriptors) - } - } - - @Test func legacyRecordsWithoutIndicesPreserveTheirTitles() throws { - let host = UUID() - let json = """ - [ - {"id":"\(UUID())","hostID":"\(host)","title":"One","kind":"ssh"}, - {"id":"\(UUID())","hostID":"\(host)","title":"Two","kind":"ssh"}, - {"id":"\(UUID())","hostID":"\(host)","title":"Files","kind":"sftp"} - ] - """ - try withStore(json) { store, _ in - let descriptors = try store.load().unassigned - #expect(descriptors.map(\.title) == ["One", "Two", "Files"]) - } - } - - @Test func currentSnapshotRoundTripsWithoutMigration() throws { - try withStore("[]") { store, defaults in - let descriptors = [ - MobileWorkspaceDescriptor(hostID: UUID(), title: "Custom", kind: .sftp) - ] - try store.save(.init(unassigned: descriptors)) - let data = defaults.data(forKey: MobileWorkspaceStore.key) - #expect(try store.load().unassigned == descriptors) - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == data) - } - } - - @Test(arguments: [ - "broken", "null", "{}", "[{}]", #"{"version":99,"workspaces":[]}"#, - #"{"version":1,"workspaces":[{}]}"#, - ]) - func invalidDataIsPreservedUntilASuccessfulLoad(json: String) throws { - try withStore(json) { store, defaults in - #expect(throws: (any Error).self) { try store.load() } - #expect(throws: (any Error).self) { try store.save(.init()) } - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == Data(json.utf8)) - defaults.set(Data("[]".utf8), forKey: MobileWorkspaceStore.key) - #expect(try store.load().unassigned.isEmpty) - try store.save(.init()) - } - } - - @Test - func duplicateLegacyIDsAreNotMigrated() throws { + @Test func activityLinksExcludeReferenceAndRejectExtraParameters() throws { let id = UUID() - let host = UUID() - let json = """ - [ - {"id":"\(id)","hostID":"\(host)","title":"One","kind":"ssh","duplicateIndex":0}, - {"id":"\(id)","hostID":"\(host)","title":"Two","kind":"ssh","duplicateIndex":1} - ] - """ - try withStore(json) { store, defaults in - #expect(throws: (any Error).self) { try store.load() } - #expect(throws: (any Error).self) { try store.save(.init()) } - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == Data(json.utf8)) - } + let reference = Data("private-ownership-marker".utf8) + let session = WorkspaceActivityAttributes.Session( + id: id, title: "Saved", kind: "ssh", connected: false, + recovery: .init(sourceWorkspaceID: id, hostID: UUID(), kind: "ssh", remoteSession: reference)) + #expect(!session.destinationURL.absoluteString.contains("private")) + #expect(WorkspaceActivityAttributes.recovery(from: session.destinationURL)?.remoteSession == nil) + #expect(WorkspaceActivityAttributes.workspaceID(from: session.destinationURL) == id) + let injected = try #require(URL(string: session.destinationURL.absoluteString + "&command=whoami")) + #expect(WorkspaceActivityAttributes.workspaceID(from: injected) == nil) } - @Test func duplicateOwnersAreRejectedWithoutOverwritingAValidSnapshot() throws { - try withStore("[]") { store, defaults in - let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Saved", kind: .ssh) - let valid = MobileWorkspaceStore.Snapshot(sessions: ["a": [descriptor]]) - try store.save(valid) - let bytes = defaults.data(forKey: MobileWorkspaceStore.key) - #expect(throws: (any Error).self) { - try store.save(.init(sessions: ["a": [descriptor], "b": [descriptor]])) - } - #expect(defaults.data(forKey: MobileWorkspaceStore.key) == bytes) - #expect(try store.load() == valid) + @Test func payloadBudgetDropsWholeEntriesAndKeepsFocusedReference() throws { + let sessions = (0..<6).map { index in + let id = UUID() + return WorkspaceActivityAttributes.Session( + id: id, title: "Session \(index)", kind: "ssh", connected: false, + recovery: .init( + sourceWorkspaceID: id, hostID: UUID(), kind: "ssh", remoteSession: Data(repeating: 42, count: 900))) + } + let state = WorkspaceActivityAttributes.ContentState.snapshot(sessions, focusedID: sessions[4].id) + #expect(try JSONEncoder().encode(state).count <= 3500) + #expect(state.sessions.count < sessions.count) + #expect(state.sessions.first?.recovery == sessions[4].recovery) + for session in state.sessions { + #expect(session.recovery?.remoteSession?.count == 900) } - } - - private func withStore( - _ json: String, check: (MobileWorkspaceStore, UserDefaults) throws -> Void - ) throws { - let suite = "termind.workspace-store-tests.\(UUID())" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set(Data(json.utf8), forKey: MobileWorkspaceStore.key) - try check(MobileWorkspaceStore(defaults: defaults), defaults) } } diff --git a/TFITests/RemoteSessionSelectionLayoutTests.swift b/TFITests/RemoteSessionSelectionLayoutTests.swift new file mode 100644 index 00000000..231e6fa4 --- /dev/null +++ b/TFITests/RemoteSessionSelectionLayoutTests.swift @@ -0,0 +1,52 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +import struct TermindDomain.Host + +@Suite @MainActor +struct RemoteSessionSelectionLayoutTests { + @Test + func longJourneyTitlesGrowVerticallyOnNarrowScreens() { + func height(title: String, width: CGFloat) -> CGFloat { + let host = Host(name: title, host: "server.invalid") + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: .sshSession, username: "user") + let controller = UIHostingController(rootView: ConnectionJourneyHeader(journey: journey) {}) + return controller.sizeThatFits(in: CGSize(width: width, height: 10_000)).height + } + + let title = "Production Application Server With a Long Descriptive Host Name" + #expect(height(title: title, width: 320) > height(title: "Server", width: 320)) + #expect(height(title: title, width: 320) > height(title: title, width: 768)) + } + + @Test(arguments: ["en", "de", "zh-Hans"]) + func sessionFormExpandsInsteadOfClippingAtAccessibilitySizes(language: String) async throws { + let coordinator = RemoteSessionCoordinator() + let task = Task { try? await coordinator.confirmPlainConnection() } + defer { + coordinator.cancel() + task.cancel() + } + for _ in 0..<100 { + if coordinator.prompt != nil { break } + await Task.yield() + } + _ = try #require(coordinator.prompt) + + func height(typeSize: DynamicTypeSize) -> CGFloat { + let controller = UIHostingController( + rootView: RemoteSessionPromptView(coordinator: coordinator, cancel: {}) + .environment(\.locale, Locale(identifier: language)) + .environment(\.dynamicTypeSize, typeSize) + .frame(width: 264)) + return controller.sizeThatFits(in: CGSize(width: 264, height: 10_000)).height + } + + #expect(height(typeSize: .accessibility3) > height(typeSize: .large)) + } +} diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index e0c5eda4..065183b8 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -1014,7 +1014,7 @@ final class AppModel { speechTranscription.cancelAll() let liveWindows = windows.values.compactMap(\.value) windows.removeAll() - for window in liveWindows { window.closeAll() } + for window in liveWindows { window.closeAll(intent: .applicationTermination) } sftpTransfers.cancelAll() externalAuthentication.cancelAll() forwarding.stopAll() diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index bae2877a..a4d93746 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -26,6 +26,10 @@ struct RootView: View { } var body: some View { + content.overlay { RemoteSessionClosePromptView(coordinator: window.persistentClose) } + } + + @ViewBuilder private var content: some View { @Bindable var window = window @Bindable var presentations = window.presentations diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 03cdf69a..778abed4 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -298,6 +298,7 @@ struct HostEditorView: View { sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: fields.startupSnippetChoice, agentForwardingEnabled: $draft.agentForwardingEnabled, + sessionPersistence: $draft.sessionPersistence, allowsAgentForwarding: !draft.isTailscaleManaged, environmentVariables: $draft.environmentVariables, snippets: app.hostLibrary.snippets, diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 02a2ebed..fe3c5fb6 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -540,7 +540,7 @@ final class SFTPChannelModel: RemotePathDisplaying, Identifiable { self.status = .disconnected self.homeResolution = .unavailable self.connection.stopRecovery() - self.connectionJourney.fail(String(localized: AppStrings.Connections.authenticationCancelled)) + self.connectionJourney.fail(String(localized: AppStrings.Connections.connectionCancelled)) } catch where host.isTailscaleManaged { guard self.connectionAttemptID == attemptID else { return } let username = suppliedAuthentication?.username ?? usernameHint ?? store.tailscaleUsername(for: host) diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 88bd3684..2b962ab1 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -93,6 +93,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { var historyID: CommandHistoryEntry.ID? } private var activeShellCommand: ActiveShellCommand? + let terminalContext = TerminalContextCoordinator() + private var hasRuntimeContext = false + private var contextHistory: [TerminalContextIdentity: UUID] = [:] private(set) var commandGeneratorRevision: UInt64 = 0 private let commandDraft = TerminalCommandDraftController() @@ -177,6 +180,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { var command: String var recordsInHistory: Bool } + let sessionPersistence = RemoteSessionCoordinator() private var pendingInitialCommand: PendingInitialCommand? /// Re-resolved from the Host and Snippet library for each physical terminal shell. private var pendingConnectionStartupCommand: PendingInitialCommand? @@ -263,6 +267,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { relay.owner = self commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor) observeWorkingDirectory(from: surface.state, through: relay) + sessionPersistence.didChangePrompt = { [weak self] pending in + if pending { self?.connectionJourney.waitForSessionSelection() } else { self?.connectionJourney.resume() } + } } var agentTemporaryTarget: AgentHostTarget? { @@ -319,10 +326,21 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { var canGenerateFromCurrentDraft: Bool { status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady && TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "") + && (!hasRuntimeContext || terminalContext.ticket(requiring: [.readDraft, .replaceDraft]) != nil) } func requestCurrentDraft() async throws -> String { - try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in + if hasRuntimeContext { + guard let ticket = terminalContext.ticket(requiring: .readDraft), + let context = terminalContext.active, let ssh + else { throw TerminalDraftError.unavailable } + let value = try await ssh.contextDraft(context) + guard terminalContext.isCurrent(ticket, requiring: .readDraft) else { + throw TerminalDraftError.unavailable + } + return value + } + return try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in self?.writeInputDirectly(data) } } @@ -332,11 +350,55 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// intentionally no carriage return in this payload. func insertUploadedPaths(_ paths: [String]) { let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " " + if hasRuntimeContext { + replaceCurrentDraft(with: text) + return + } // Write only to this session: broadcast input must not copy local upload paths. terminalViewOwner.pasteUploadedText(text) } + var directoryContextTicket: TerminalContextCoordinator.Ticket? { + terminalContext.ticket(requiring: .directory) + } + + func isDirectoryContextCurrent(_ ticket: TerminalContextCoordinator.Ticket?) -> Bool { + guard hasRuntimeContext else { return ticket == nil } + guard let ticket else { return false } + return terminalContext.isCurrent(ticket, requiring: .directory) + } + + func confirmDirectoryContext(_ ticket: TerminalContextCoordinator.Ticket?) async throws { + guard hasRuntimeContext else { return } + guard isDirectoryContextCurrent(ticket), let context = terminalContext.active, let ssh else { + throw TerminalDraftError.unavailable + } + do { + try await ssh.confirmContext(context) + guard isDirectoryContextCurrent(ticket) else { throw TerminalDraftError.unavailable } + } catch { + if isDirectoryContextCurrent(ticket) { receiveRuntimeContext(.init(status: .unknown)) } + throw error + } + } + func replaceCurrentDraft(with command: String) { + if hasRuntimeContext { + guard let ticket = terminalContext.ticket(requiring: .replaceDraft), + let context = terminalContext.active, let ssh + else { return } + Task { [weak self] in + guard let self, terminalContext.isCurrent(ticket, requiring: .replaceDraft) else { return } + do { + _ = try await ssh.contextDraft(context, replacing: command) + } catch { + if terminalContext.isCurrent(ticket, requiring: .replaceDraft) { + receiveRuntimeContext(.init(status: .unknown)) + } + } + } + return + } writeInputDirectly(commandDraft.replacementPayload(for: command)) } @@ -346,6 +408,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } private func resetCommandGeneratorShellState() { + terminalContext.reset() + hasRuntimeContext = false + contextHistory.removeAll() integratedShell = nil remoteSystemInfo = nil isShellPromptReady = false @@ -620,7 +685,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { connectionAttemptID = nil status = .disconnected connection.stopRecovery() - connectionJourney.fail(String(localized: AppStrings.Connections.authenticationCancelled)) + connectionJourney.fail(String(localized: AppStrings.Connections.connectionCancelled)) } catch { guard connectionAttemptID == attemptID else { return } connectionAttemptID = nil @@ -717,6 +782,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } func disconnect() { + sessionPersistence.cancel() commandHistory.cancelLoading() terminalUpload.cancelPendingUpload() commandMonitor.endConnection() @@ -775,6 +841,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { configuration.tailscaleNodeID = host.tailscale?.stableNodeID let terminalSurface = terminalSurface commandMonitor.beginConnection() + commandMonitor.requireScopedEvents() let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel( output: { data in terminalSurface.receive(data) }, exit: { [weak self] result in @@ -797,6 +864,24 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list)) terminalSurface.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8)) } + let persistence = sessionPersistence + let explicit = pendingInitialCommand?.command + let startup = explicit ?? pendingConnectionStartupCommand?.command + let integration = isShellIntegrationEnabled + if host.sessionPersistence.enabled || persistence.attachment != nil || persistence.requiresSelection { + persistence.didChangeAttachment = { [weak self] reference in + guard let self, self.connectionAttemptID == attemptID, reference != nil else { return } + self.pendingInitialCommand = nil + self.pendingConnectionStartupCommand = nil + } + await actor.configureStartup { execute in + try await persistence.prepare( + preferences: host.sessionPersistence, registry: .live, execute: execute, + hostID: host.id, endpoint: "\(host.host):\(host.sshPort)", username: username, + startupCommand: startup, requiresNewSession: explicit != nil, shellIntegration: integration, + environment: host.environmentVariables) + } + } try await actor.connect( using: SSHConnectionRequest( configuration: configuration, @@ -825,13 +910,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { temporaryHostKeys.removeAll() _ = connection.markConnected() status = .connected - commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil) + if sessionPersistence.attachment == nil { + commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil) + } else { + pendingInitialCommand = nil + pendingConnectionStartupCommand = nil + homeResolution = .unavailable + } if presentsConnectionJourney { connectionJourney.succeed() } else { connectionJourney.hide() } - if wasReconnecting { + if wasReconnecting, sessionPersistence.attachment == nil { appendTerminalNotice( String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted) ) @@ -852,7 +943,69 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// makes the surface permanently stop producing input, which is incorrect /// for Termind's retained pane model. Close the pane through its current /// Workspace instead; transport failures remain visible for reconnection. + func terminatePersistentSession( + _ reference: RemoteSessionReference, isCurrentOwner: @MainActor () -> Bool = { true } + ) async throws { + guard sessionPersistence.attachment == reference, let channel = ssh else { + throw RemoteSessionFailure.sessionMissing + } + try await channel.releaseTerminalForControl() + status = .disconnected + resetCommandGeneratorShellState() + workingDirectory = nil + let execute = try await channel.sessionCommandExecutor() + let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute) + let sessions = try await backend.sessions() + guard isCurrentOwner(), ssh === channel, sessionPersistence.attachment == reference else { + throw CancellationError() + } + guard sessions.contains(where: { backend.matches(reference, session: $0) }) else { + throw RemoteSessionFailure.ownershipMismatch + } + try await backend.terminate(reference) + if sessionPersistence.attachment == reference { sessionPersistence.markEnded() } + } + func handleRemoteExit(_ result: Result) { + resetCommandGeneratorShellState() + workingDirectory = nil + terminalUpload.cancelPendingUpload() + if case .success(let exitCode) = result, let reference = sessionPersistence.attachment, let channel = ssh { + let attemptID = connectionAttemptID + Task { [weak self] in + guard let self else { return } + var sessionExists: Bool? + do { + let execute = try await channel.sessionCommandExecutor() + let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute) + let sessions = try await backend.sessions() + sessionExists = sessions.contains(where: { backend.matches(reference, session: $0) }) + } catch { + // An unverified exit must not erase identity or start a replacement session. + } + guard self.connectionAttemptID == attemptID, self.sessionPersistence.attachment == reference else { + return + } + let shouldClose = self.sessionPersistence.finishExit(exitCode: exitCode, sessionExists: sessionExists) + await channel.disconnect() + guard self.connectionAttemptID == attemptID else { return } + self.ssh = nil + self.connection.resetRecovery() + self.status = .disconnected + if shouldClose { + self.inputRouter?.closeSSHSessionPane(afterExit: self) + return + } + self.appendTerminalNotice( + String( + localized: exitCode != 0 + ? AppStrings.SessionPersistence.failed + : self.sessionPersistence.detached + ? AppStrings.SessionPersistence.detached : AppStrings.SessionPersistence.ended)) + } + return + } + resetCommandGeneratorShellState() workingDirectory = nil homeResolution = .unavailable @@ -1004,6 +1157,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.sessionPersistence.attachment = sessionPersistence.attachment copy.connectionRoute = connectionRoute copy.connectionOrigin = connectionOrigin copy.connectionCoordinator = connectionCoordinator @@ -1025,6 +1179,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// The terminal side of the routing boundary. Broadcast targets call this /// rather than feeding bytes back through the router, preventing recursion. func writeInputDirectly(_ data: Data) { + terminalContext.noteInput() if let inputSink { inputSink(data) return @@ -1053,15 +1208,24 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { ) { workingDirectoryObservation = state.$workingDirectory.sink { [weak relay] path in Task { @MainActor in - relay?.owner?.workingDirectory = path?.isEmpty == false ? path : nil + guard let owner = relay?.owner, owner.isShellIntegrationEnabled, !owner.hasRuntimeContext else { + return + } + owner.workingDirectory = path?.isEmpty == false ? path : nil } } } func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) { guard isShellIntegrationEnabled else { return } + if case .runtime(let update) = event { + receiveRuntimeContext(update) + return + } + guard !hasRuntimeContext else { return } commandMonitor.receiveShellEvent(event) switch event { + case .runtime: break case .available(let shell, let systemInfo, let homeDirectory): if let homeDirectory, homeDirectory.hasPrefix("/") { homeResolution = .resolved(RemotePath.normalized(homeDirectory)) @@ -1103,6 +1267,44 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } } + private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) { + hasRuntimeContext = true + let previous = terminalContext.revision + let events = terminalContext.receive(update, generation: terminalContext.generation) + commandMonitor.receiveRuntimeEvents(events) + if previous != terminalContext.revision { + commandDraft.cancel() + terminalUpload.cancelPendingUpload() + invalidateCommandGeneration() + } + workingDirectory = terminalContext.directory + integratedShell = terminalContext.active?.shell + shellIntegrationAvailable = terminalContext.active?.capabilities.contains(.commandLifecycle) == true + isShellPromptReady = terminalContext.active?.promptReady == true + if let home = terminalContext.active?.home { + homeResolution = .resolved(RemotePath.normalized(home)) + } else { + homeResolution = .unavailable + } + for event in events { + switch event.payload { + case .commandStarted(let command): + contextHistory[event.context] = recordExecutedCommand(command) + case .commandFinished(let status, let duration): + if let id = contextHistory.removeValue(forKey: event.context) { + commandHistory.recordCompletion(for: id, exitStatus: status, duration: duration) + } + case .draft(_, let value): + if terminalContext.active?.identity == event.context { commandDraft.receive(value) } + default: break + } + } + if update.status == .ordinary, isShellPromptReady { + isInitialCommandSubmissionReady = true + submitPendingInitialCommandIfReady() + } + } + /// Submits an explicit one-shot launch command, or this connection's freshly /// resolved Host startup command. Keeping the History exclusion next to the /// write ensures it is armed only when the command actually runs. diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index d545d8c2..7d989f85 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -131,7 +131,7 @@ struct TerminalPlaceholderView: View { @ViewBuilder private var connectionJourney: some View { - if isVisible, sshSession.connectionJourney.isPresented { + if isVisible, sshSession.connectionJourney.isPresented || sshSession.sessionPersistence.prompt != nil { ConnectionJourneyView( journey: sshSession.connectionJourney, authenticationPrompt: sshSession.pendingAuthentication, @@ -164,7 +164,8 @@ struct TerminalPlaceholderView: View { retryReconnectNow: sshSession.retryReconnectNow, stopReconnecting: sshSession.stopReconnecting, securityKeySigning: sshSession.connection.securityKeySigning, - keyboardAuthentication: sshSession.connection.keyboardAuthentication + keyboardAuthentication: sshSession.connection.keyboardAuthentication, + remoteSession: sshSession.sessionPersistence ) } } @@ -182,7 +183,6 @@ struct TerminalPlaceholderView: View { } private func cancelConnection() { - sshSession.disconnect() closePane() } diff --git a/TFM/Features/Terminal/TerminalUploadModel.swift b/TFM/Features/Terminal/TerminalUploadModel.swift index 3a11261a..45e64dc2 100644 --- a/TFM/Features/Terminal/TerminalUploadModel.swift +++ b/TFM/Features/Terminal/TerminalUploadModel.swift @@ -78,6 +78,7 @@ final class TerminalUploadModel { struct Target: Equatable { let generation: UUID? let destination: String + var context: TerminalContextCoordinator.Ticket? = nil } private weak var session: SSHSessionModel? @@ -91,6 +92,7 @@ final class TerminalUploadModel { private var uploadPreparationTask: Task? private var uploadPreparationID: UUID? private var uploadEnqueueTask: Task? + private var pendingContext: TerminalContextCoordinator.Ticket? init(session: SSHSessionModel) { self.session = session } @@ -106,7 +108,8 @@ final class TerminalUploadModel { var target: Target? { guard uploadTargetProblem == nil, let session, let destination = session.workingDirectory else { return nil } - return Target(generation: session.connectionAttemptID, destination: destination) + return Target( + generation: session.connectionAttemptID, destination: destination, context: session.directoryContextTicket) } @discardableResult @@ -155,6 +158,8 @@ final class TerminalUploadModel { self.transfers = transfers let destination = RemotePath.normalized(workingDirectory) let generation = sshSession.connectionAttemptID + let context = sshSession.directoryContextTicket + pendingContext = context let preparationID = UUID() uploadPreparationID = preparationID pendingUploadItemCount = request.itemCount @@ -169,6 +174,7 @@ final class TerminalUploadModel { } var connectedChannel: (any SFTPChannelProtocol)? do { + try await sshSession.confirmDirectoryContext(context) let resolved = try await TerminalUploadConnectionResolver.resolve( isCurrent: { sshSession.connectionAttemptID == generation }, connectIndependent: { @@ -194,6 +200,7 @@ final class TerminalUploadModel { homeResolution: sshSession.homeResolution ) await request.prepare(on: uploadChannel, coordinator: transfers) + try await sshSession.confirmDirectoryContext(context) try Task.checkCancellation() guard sshSession.connectionAttemptID == generation else { throw CancellationError() } if let error = uploadChannel.operationError { @@ -235,8 +242,16 @@ final class TerminalUploadModel { let destination = uploadChannel.path let generation = sshSession.connectionAttemptID let insertPaths = insertUploadedPaths + let context = pendingContext hasConflict = false uploadEnqueueTask = Task { + do { + try await sshSession.confirmDirectoryContext(context) + try Task.checkCancellation() + } catch { + cancelPendingUpload() + return + } if uploadChannel.pendingRemoteCopyBatch != nil { await uploadChannel.enqueuePendingRemoteCopy( policy: policy, @@ -249,7 +264,7 @@ final class TerminalUploadModel { uploadedPaths: insertPaths ? { [weak session = sshSession] paths in guard let session, session.status == .connected, - session.connectionAttemptID == generation + session.connectionAttemptID == generation, session.isDirectoryContextCurrent(context) else { return } session.insertUploadedPaths(paths) } : nil @@ -273,6 +288,7 @@ final class TerminalUploadModel { } func cancelPendingUpload() { + pendingContext = nil uploadPreparationTask?.cancel() uploadPreparationTask = nil uploadPreparationID = nil diff --git a/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift index f4b44351..745a0cd6 100644 --- a/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift +++ b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift @@ -10,12 +10,14 @@ struct ClosedWorkspaceRecord: Codable, Identifiable { let layout: WorkspaceTemplateLayout? let paneNames: [String?] let focusedPaneIndex: Int + var remoteSessions: [RemoteSessionReference?]? = nil @MainActor init(_ workspace: Workspace, hosts: HostLibraryModel) { let layout = workspace.panes.contains { $0.sshSession?.isExternalTemporaryConnection == true } ? nil : workspace.workspaceTemplateLayout(in: hosts) + self.remoteSessions = workspace.panes.map { $0.sshSession?.sessionPersistence.attachment } self.id = workspace.id self.title = workspace.title(in: hosts) self.symbol = workspace.symbol @@ -44,6 +46,9 @@ struct ClosedWorkspaceRecord: Codable, Identifiable { let workspace = Workspace(pane: node.firstPane) workspace.layout = node for (pane, name) in zip(workspace.panes, paneNames) { pane.customTitle = name } + for (pane, reference) in zip(workspace.panes, remoteSessions ?? []) { + pane.sshSession?.sessionPersistence.attachment = reference + } if workspace.isSplit { workspace.customTitle = title } else { workspace.automaticTitle = title } if workspace.panes.indices.contains(focusedPaneIndex) { workspace.focusedPaneID = workspace.panes[focusedPaneIndex].id diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index d8351560..18b229a7 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -321,6 +321,7 @@ final class Pane: Identifiable { switch content { case .sshSession(let sshSession): let clone = sshSession.copyForReconnect() + clone.sessionPersistence.clearAttachment() clone.status = .connecting return Pane(.sshSession(clone)) case .telnetSession(let telnetSession): diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 6dfef56f..62106e1f 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -867,6 +867,23 @@ final class WindowModel { @discardableResult func openCompanionSession(from sessionID: UUID, placement: SessionOpenPlacement) -> Workspace? { guard let source = companionSessionSource(sessionID) else { return nil } + if let session = source.pane.sshSession, let ticket = session.directoryContextTicket { + Task { [weak self, weak session] in + guard let self, let session else { return } + do { + try await session.confirmDirectoryContext(ticket) + guard let current = companionSessionSource(sessionID), current.path == source.path, + current.workspace.id == source.workspace.id, session.isDirectoryContextCurrent(ticket) + else { return } + if placement == .currentWorkspace { + select(current.workspace.id) + current.workspace.focus(current.pane.id) + } + openSFTPChannel(host: current.host, initialPath: current.path, placement: placement) + } catch {} + } + return nil + } if placement == .currentWorkspace { select(source.workspace.id) source.workspace.focus(source.pane.id) @@ -1385,6 +1402,7 @@ final class WindowModel { /// Moving preserves live transports and does not populate close history. func takeWorkspaceForMove(_ id: Workspace.ID) -> Workspace? { guard !isClosed, let workspace = workspaces.first(where: { $0.id == id }) else { return nil } + persistentClose.cancel() removeWorkspaceForMove(id) workspace.lifecycleRouter = nil dragging = nil @@ -1404,6 +1422,7 @@ final class WindowModel { guard !isClosed, let pane = pane(id), pane.isCombinable, let source = workspace(holding: id) else { return nil } + persistentClose.cancel() if source.panes.count == 1 { return takeWorkspaceForMove(source.id) } guard source.closePane(id) else { return nil } let workspace = automaticallyNamedWorkspace(pane: pane) @@ -1413,6 +1432,41 @@ final class WindowModel { return workspace } + let persistentClose = RemoteSessionCloseCoordinator() + var persistentWindowCloseAuthorized = false + + var hasManagedSessions: Bool { + workspaces.flatMap(\.panes).contains { $0.sshSession?.sessionPersistence.attachment?.isManaged == true } + } + + func authorizePersistentClose(_ sessions: [SSHSessionModel]) async -> Bool { + let workspaceIDs = Set(workspaces.map(\.id)) + let paneIDs = Set(workspaces.flatMap(\.panes).map(\.id)) + let references = sessions.compactMap { $0.sessionPersistence.attachment } + let allowed = await persistentClose.close(attachments: references) { [weak self] reference in + guard let self, let session = sessions.first(where: { $0.sessionPersistence.attachment == reference }), + self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session }) + else { throw CancellationError() } + try await session.terminatePersistentSession(reference) { + self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session }) + } + for session in sessions + where session.sessionPersistence.attachment?.hasSameIdentity(as: reference) == true { + session.sessionPersistence.markEnded() + } + } + // Confirmation covers this snapshot, never workspaces added or moved while it was open. + return allowed && workspaceIDs == Set(workspaces.map(\.id)) + && paneIDs == Set(workspaces.flatMap(\.panes).map(\.id)) + } + + private func deferPersistentClose(_ sessions: [SSHSessionModel], action: @escaping @MainActor () -> Void) -> Bool { + guard sessions.contains(where: { $0.sessionPersistence.attachment?.isManaged == true }) else { return false } + guard !persistentClose.isClosing else { return true } + Task { if await authorizePersistentClose(sessions) { action() } } + return true + } + func closeForWindow() { guard !isClosed else { return } rememberClosedWindow?(workspaces, selectedWorkspaceID) @@ -1432,7 +1486,13 @@ final class WindowModel { // MARK: - Closing - func close(_ id: Workspace.ID) { + func close(_ id: Workspace.ID, authorized: Bool = false) { + if !authorized, let target = workspaces.first(where: { $0.id == id }), + deferPersistentClose( + target.panes.compactMap(\.sshSession), action: { [weak self] in self?.close(id, authorized: true) }) + { + return + } guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } let removed = workspaces.remove(at: index) removed.disconnectConnections() @@ -1457,7 +1517,14 @@ final class WindowModel { closePane(workspace.focusedPaneID) } - func closeOthers(than id: Workspace.ID) { + func closeOthers(than id: Workspace.ID, authorized: Bool = false) { + if !authorized, + deferPersistentClose( + workspaces.filter { $0.id != id }.flatMap(\.panes).compactMap(\.sshSession), + action: { [weak self] in self?.closeOthers(than: id, authorized: true) }) + { + return + } for workspace in workspaces where workspace.id != id { workspace.disconnectConnections() rememberClosedWorkspace?(workspace) @@ -1466,7 +1533,15 @@ final class WindowModel { selectedWorkspaceID = id } - func closeAll() { + func closeAll(intent: TerminalTeardownIntent = .explicitClose, authorized: Bool = false) { + if !intent.endsManagedSession { persistentClose.cancel() } + if !authorized, intent.endsManagedSession, + deferPersistentClose( + workspaces.flatMap(\.panes).compactMap(\.sshSession), + action: { [weak self] in self?.closeAll(authorized: true) }) + { + return + } for workspace in workspaces { workspace.disconnectConnections() rememberClosedWorkspace?(workspace) @@ -1496,10 +1571,17 @@ final class WindowModel { /// The sidebar's rows are panes, so its Close acts on a pane — closing the /// whole Workspace because you dismissed one of its three shells would take /// two things you did not point at. - func closePane(_ paneID: Pane.ID) { + func closePane(_ paneID: Pane.ID, authorized: Bool = false) { guard let workspace = workspace(holding: paneID), let pane = workspace.layout.pane(paneID) else { return } guard workspace.isSplit else { - close(workspace.id) + close(workspace.id, authorized: authorized) + return + } + if !authorized, + deferPersistentClose( + [pane.sshSession].compactMap { $0 }, + action: { [weak self] in self?.closePane(paneID, authorized: true) }) + { return } pane.disconnectConnection() diff --git a/TFM/Platform/WorkspaceCloseConfirmation.swift b/TFM/Platform/WorkspaceCloseConfirmation.swift index 3c984e83..54ee4b78 100644 --- a/TFM/Platform/WorkspaceCloseConfirmation.swift +++ b/TFM/Platform/WorkspaceCloseConfirmation.swift @@ -68,9 +68,24 @@ nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate @MainActor func windowShouldClose(_ sender: NSWindow) -> Bool { + if model.persistentWindowCloseAuthorized { return true } guard !isConfirming else { return false } guard forwardedDelegate?.windowShouldClose?(sender) != false else { return false } guard !model.isClosed, !model.workspaces.isEmpty else { return true } + if model.hasManagedSessions { + isConfirming = true + Task { [weak self, weak sender] in + guard let self else { return } + let allowed = await model.authorizePersistentClose( + model.workspaces.flatMap(\.panes).compactMap(\.sshSession)) + isConfirming = false + if allowed { + model.persistentWindowCloseAuthorized = true + sender?.performClose(nil) + } + } + return false + } isConfirming = true defer { isConfirming = false } return confirmClose() diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index a971854f..9fa50e51 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -163,13 +163,14 @@ struct ConnectionJourneyView: View { var securityKeySigning: SecurityKeySigningCoordinator? = nil var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil var cancelKeyboardAuthentication: (() -> Void)? = nil + var remoteSession: RemoteSessionCoordinator? = nil var isEmbedded = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Group { - if isEmbedded { + if isEmbedded || remoteSession?.prompt != nil { ScrollView { journeyContent } } else { journeyContent @@ -199,7 +200,7 @@ struct ConnectionJourneyView: View { content .frame(maxWidth: 660) .padding(.horizontal, isEmbedded ? 16 : 28) - .fixedSize(horizontal: false, vertical: isEmbedded) + .fixedSize(horizontal: false, vertical: isEmbedded || remoteSession?.prompt != nil) Spacer(minLength: 24) } @@ -242,6 +243,9 @@ struct ConnectionJourneyView: View { trust: { trustHostKey(hostKey.observation) }, deny: denyHostKey ) + } else if let remoteSession, remoteSession.prompt != nil { + RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel) + .desktopConnectionFormChrome(isEnabled: true) } else if case .failed(let message) = journey.phase { JourneyFailureCard( message: message, retry: retry, cancel: cancel, diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index e72477bb..f0c14222 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -7,6 +7,41 @@ import Testing @testable import Termind struct AppLanguageTests { + @Test(arguments: [ + ("en", "Connection was cancelled. Retry to connect."), + ("zh-Hans", "已取消连接。重试即可连接。"), + ("zh-Hant", "已取消連線。重試即可連線。"), + ("ja", "接続がキャンセルされました。再試行すると接続できます。"), + ("ko", "연결이 취소되었습니다. 연결하려면 다시 시도하세요."), + ("de", "Die Verbindung wurde abgebrochen. Versuchen Sie erneut, eine Verbindung herzustellen."), + ("fr", "La connexion a été annulée. Réessayez pour vous connecter."), + ]) + func connectionCancellationDoesNotClaimAuthenticationWasCancelled(language: String, expected: String) { + #expect( + AppLocalization.resolve( + AppStrings.Connections.connectionCancelled, locale: Locale(identifier: language)) == expected) + } + + @Test + func sessionPersistenceOptionsUseTitleCaseAndHaveTranslations() { + let labels: [(LocalizedStringResource, String)] = [ + (AppStrings.Common.enabled, "Enabled"), + (AppStrings.Common.new, "New"), + (AppStrings.Common.skip, "Skip"), + (AppStrings.SessionPersistence.section, "Session Persistence"), + (AppStrings.SessionPersistence.onConnect, "On Connect"), + (AppStrings.SessionPersistence.ask, "Ask Every Time"), + (AppStrings.SessionPersistence.create, "New Managed Session"), + (AppStrings.SessionPersistence.normalShell, "Use a Normal Shell"), + ] + for (resource, english) in labels { + #expect(AppLocalization.resolve(resource, locale: Locale(identifier: "en")) == english) + for language in ["zh-Hans", "zh-Hant", "ja", "ko", "de", "fr"] { + #expect(AppLocalization.resolve(resource, locale: Locale(identifier: language)) != english) + } + } + } + @Test func languagePickerKeepsSystemFirstThenSortsByLanguageCode() { #expect(AppLanguage.allCases.first == .system) diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 280badec..9e90e7b0 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -3,6 +3,26 @@ import Testing @Suite struct ArchitectureBoundaryTests { + @Test + func sessionPersistencePolicyDoesNotDependOnTheTmuxImplementation() throws { + for directory in [ + "../Frameworks/TermindDomain", "../Frameworks/TermindApplication", + "../Frameworks/TermindPresentation", "Features", "../TFI/Features", + ] { + for file in try swiftFiles(in: directory) { + let text = try String(contentsOf: file, encoding: .utf8) + for symbol in ["RemoteTmuxService", "RemoteTmuxCommands", "RemoteTmuxShellStartup"] { + #expect(!containsToken(symbol, in: text), "\(file.path) depends on \(symbol)") + } + } + } + let channel = try String( + contentsOf: sourceRoot.appending(path: "../Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift"), + encoding: .utf8) + #expect(!containsToken("RemoteTmuxService", in: channel)) + #expect(containsToken("StartupPreparation", in: channel)) + } + @Test func embeddedTailscaleStaysBehindRemoteInfrastructure() throws { for module in ["TermindDomain", "TermindApplication", "TermindPresentation", "TermindPersistence"] { diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index a27caa1d..7e969742 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -9,6 +9,34 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct HostShellIntegrationSessionTests { + @Test func runtimeContextGatesEnhancementsWithoutPersistence() throws { + let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled) + let session = SSHSessionModel(hostID: host.id, title: host.name) + session.connect(using: HostLibraryModel(hosts: [host])) + defer { session.disconnect() } + session.status = .connected + let context = TerminalShellContext( + identity: .init(backend: "test-multiplexer", server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: "/pane-a", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + session.receiveShellIntegrationEvent( + .runtime( + .init( + status: .multiplexer, active: context.identity, contexts: [context]))) + #expect(!host.sessionPersistence.enabled) + #expect(session.canGenerateFromCurrentDraft) + #expect(session.workingDirectory == "/pane-a") + let ticket = try #require(session.directoryContextTicket) + session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown))) + #expect(session.workingDirectory == nil) + #expect(!session.canGenerateFromCurrentDraft) + #expect(!session.isDirectoryContextCurrent(ticket)) + session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/stale")) + session.receiveShellIntegrationEvent(.promptReady) + #expect(!session.canGenerateFromCurrentDraft) + #expect(session.workingDirectory == nil) + } + @Test(arguments: HostShellIntegrationMode.allCases, [false, true]) func connectionRetainsItsPreferenceUntilTheNextAttempt(mode: HostShellIntegrationMode, global: Bool) { let defaults = UserDefaults.standard diff --git a/TFMTests/RemoteSessionWireTests.swift b/TFMTests/RemoteSessionWireTests.swift new file mode 100644 index 00000000..cb1f3ffc --- /dev/null +++ b/TFMTests/RemoteSessionWireTests.swift @@ -0,0 +1,362 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +/// Opt-in: Scripts/ssh-tmux-session-fixture.py runs isolated OpenSSH with MaxSessions=1 and tmux >= 3.3. +/// The JSON fixture holds only paths and a pinned test-server fingerprint. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil), .serialized) +@MainActor +struct RemoteSessionWireTests { + private struct Fixture: Decodable { + let port: Int + let hostFingerprint: String + let privateKeyPath: String + } + + @Test(.timeLimit(.minutes(1))) + func preparationMultiClientDetachRestoreAndOwnedTermination() async throws { + let fixture = try loadFixture() + let hostID = UUID() + let coordinator = RemoteSessionCoordinator() + let selectionMonitor = Task { + while !Task.isCancelled { + if let prompt = coordinator.prompt { + switch prompt.failure { + case .commandFailed(let message): + let redacted = message.replacingOccurrences( + of: #"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}"#, + with: "", options: .regularExpression) + Issue.record("Unexpected remote command failure during create/restore: \(redacted)") + case .invalidResponse: + Issue.record("Unexpected remote response during create/restore") + case .ownershipMismatch: + Issue.record("Unexpected ownership mismatch during create/restore") + default: + Issue.record("Unexpected session selection during automatic create/restore") + } + coordinator.cancel() + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + } + defer { selectionMonitor.cancel() } + let marker = "/tmp/termind-once-\(UUID().uuidString)" + let channel = SSHPTYChannel( + output: { _ in }, exit: { _ in }, maxSessionsPolicy: .init(preferences: .init(maxSessions: 1))) + await channel.configureStartup { execute in + try await coordinator.prepare( + preferences: .init(enabled: true, onConnect: .createManaged), registry: .live, + execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root", + startupCommand: "printf once >> '\(marker)'", requiresNewSession: true, shellIntegration: false) + } + try await connect(channel, fixture: fixture, hostID: hostID) + let reference = try #require(coordinator.attachment) + #expect(reference.isManaged) + let owner = try #require(reference.ownershipToken) + #expect(!reference.session.name.lowercased().contains(owner.uuidString.lowercased())) + await channel.resize(columns: 103, rows: 37, widthPixels: 0, heightPixels: 0) + try await Task.sleep(for: .milliseconds(250)) + + let second = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await second.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let before = try await backend.sessions() + #expect(before.contains { backend.matches(reference, session: $0) && $0.clients == 1 }) + let target = try RemoteTmuxCommands.quote(reference.session.id) + let size = try await execute("tmux list-clients -t \(target) -F '#{client_width} #{client_height}'") + #expect(size.stdout == "103 37\n") + return SSHPTYStartupPlan(command: try backend.launch(reference, created: false).command) + } + try await connect(second, fixture: fixture, hostID: hostID) + let observer = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await observer.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let sessions = try await backend.sessions() + #expect(sessions.contains { backend.matches(reference, session: $0) && $0.clients == 2 }) + return nil + } + try await connect(observer, fixture: fixture, hostID: hostID) + await observer.disconnect() + // Release the first terminal before executing control commands on its MaxSessions=1 transport. + try await channel.releaseTerminalForControl() + let execute = try await channel.sessionCommandExecutor() + let backend = RemoteTmuxService(execute: execute) + let listed = try await backend.sessions() + #expect(listed.contains { backend.matches(reference, session: $0) && $0.clients == 1 }) + #expect(try await execute("cat '\(marker)'").stdout == "once") + await second.disconnect() + #expect(try await backend.sessions().contains { backend.matches(reference, session: $0) }) + + let third = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await third.configureStartup { execute in + try await coordinator.prepare( + preferences: .init(enabled: true, onConnect: .createManaged), registry: .live, + execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root", + startupCommand: "printf REPLAY >> '\(marker)'", requiresNewSession: false, shellIntegration: false) + } + try await connect(third, fixture: fixture, hostID: hostID) + #expect(try await execute("cat '\(marker)'").stdout == "once") + let external = RemoteSessionReference( + backend: reference.backend, session: reference.session, + hostID: reference.hostID, endpoint: reference.endpoint, username: reference.username, ownershipToken: nil) + do { + try await backend.terminate(external) + Issue.record("External attachment cannot terminate") + } catch RemoteSessionFailure.ownershipMismatch {} + try await backend.terminate(reference) + #expect(try await backend.sessions().allSatisfy { !backend.matches(reference, session: $0) }) + _ = try await execute("rm -f '\(marker)'") + await third.disconnect() + await channel.disconnect() + } + + @Test(.timeLimit(.minutes(1)), arguments: ["bash", "zsh", "fish"]) + func managedShellLoadsUserConfigurationAndDoesNotReplayStartup(shell: String) async throws { + let fixture = try loadFixture() + let hostID = UUID() + let directory = "/tmp/termind-shell-test-\(UUID().uuidString)" + let channel = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await channel.configureStartup { execute in + let quotedDirectory = try RemoteTmuxCommands.quote(directory) + let config = shell == "fish" ? "set -gx TEST_SESSION_CONFIG loaded" : "export TEST_SESSION_CONFIG=loaded" + let contents = try RemoteTmuxCommands.quote( + config + "\nprintf 'config\\n' >> '\(directory)/config-count'\n") + let setup = try await execute( + """ + umask 077 + mkdir -p \(quotedDirectory)/.config/fish + printf '%s\\n' \(contents) > \(quotedDirectory)/.bash_profile + printf '%s\\n' \(contents) > \(quotedDirectory)/.zshrc + printf '%s\\n' \(contents) > \(quotedDirectory)/.config/fish/config.fish + """) + #expect(setup.exitCode == 0) + let backend = RemoteTmuxService(execute: execute) + let token = UUID() + let session = try await backend.create( + token: token, + startupCommand: + "cd /tmp; printf '%s' \"$TEST_SESSION_CONFIG\" > '\(directory)/config-value'; printf once >> '\(directory)/once'", + shellIntegration: true, + environment: [ + .init(variable: "SHELL", value: "/bin/\(shell)"), + .init(variable: "HOME", value: directory), + .init(variable: "XDG_CONFIG_HOME", value: "\(directory)/.config"), + ]) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: hostID, endpoint: "localhost:\(fixture.port)", + username: "root", ownershipToken: token) + let target = try RemoteTmuxCommands.quote(session.id) + var initialized = false + for _ in 0..<100 { + if try await execute("test -f \(quotedDirectory)/once").exitCode == 0 { + initialized = true + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(initialized) + let configuredValue = try await execute("cat \(quotedDirectory)/config-value").stdout + #expect(configuredValue == "loaded") + let actualShell = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout + #expect(actualShell == "\(shell)\n") + #expect(try await execute("tmux display-message -p -t \(target) '#{pane_current_path}'").stdout == "/tmp\n") + #expect(try await execute("tmux new-window -t \(target)").exitCode == 0) + var configCount = "" + for _ in 0..<100 { + configCount = try await execute("cat \(quotedDirectory)/config-count").stdout + if configCount == "config\nconfig\n" { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(configCount == "config\nconfig\n") + let shellPassthrough = try await execute("tmux show-option -A -w -v -t \(target) allow-passthrough") + #expect(shellPassthrough.stdout == "off\n") + let journals = try await execute( + """ + helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) + helper=${helper#TERMIND_TMUX_HELPERS=} + for file in "$helper"/ctx.*/journal; do head -n 2 "$file"; done + """) + #expect(journals.stdout.components(separatedBy: "\n").filter { $0.hasPrefix("M1|") }.count == 2) + #expect(journals.stdout.contains("|\(shell)|")) + let programWindow = try await execute("tmux new-window -P -F '#{window_id}' -t \(target) 'sleep 120'") + #expect(programWindow.exitCode == 0) + let programTarget = try RemoteTmuxCommands.quote( + programWindow.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) + let passthrough = try await execute("tmux show-option -A -w -v -t \(programTarget) allow-passthrough") + #expect(passthrough.stdout == "off\n") + #expect(try await execute("cat \(quotedDirectory)/once").stdout == "once") + try await backend.terminate(reference) + #expect(try await backend.sessions().allSatisfy { !backend.matches(reference, session: $0) }) + _ = try await execute("rm -rf \(quotedDirectory)") + return nil + } + try await connect(channel, fixture: fixture, hostID: hostID) + await channel.disconnect() + } + + @Test(.timeLimit(.minutes(1))) + func abruptTransportLossPreservesTheSessionAndDoesNotReplayStartup() async throws { + let fixture = try loadFixture() + let hostID = UUID() + let coordinator = RemoteSessionCoordinator() + let state = ConnectionState() + let marker = "/tmp/termind-network-test-\(UUID().uuidString)" + let channel = SSHPTYChannel( + output: { _ in }, + exit: { result in + Task { @MainActor in state.exit = result } + }) + await channel.configureStartup { execute in + let parent = try await execute("printf '%s' \"$PPID\"").stdout + await MainActor.run { state.serverPID = UInt64(parent) } + return try await coordinator.prepare( + preferences: .init(enabled: true, onConnect: .createManaged), registry: .live, + execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root", + startupCommand: "printf once >> '\(marker)'", requiresNewSession: false, shellIntegration: false) + } + try await connect(channel, fixture: fixture, hostID: hostID) + let reference = try #require(coordinator.attachment) + let serverPID = try #require(state.serverPID) + let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await control.configureStartup { _ in nil } + try await connect(control, fixture: fixture, hostID: hostID) + try await control.releaseTerminalForControl() + let execute = try await control.sessionCommandExecutor() + // Kill only this fixture connection's sshd process, not the tmux server or listener. + let process = try await execute("ps -o comm= -p \(serverPID)").stdout + try #require(process.contains("sshd")) + #expect(try await execute("kill -KILL \(serverPID)").exitCode == 0) + for _ in 0..<200 { + if state.exit != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + guard case .failure = state.exit else { + Issue.record("Abrupt transport loss must report a connection failure, not a detach") + await channel.disconnect() + await control.disconnect() + return + } + let backend = RemoteTmuxService(execute: execute) + #expect(try await backend.sessions().contains { backend.matches(reference, session: $0) }) + try await connect(channel, fixture: fixture, hostID: hostID) + #expect(coordinator.attachment?.hasSameIdentity(as: reference) == true) + #expect(try await execute("cat '\(marker)'").stdout == "once") + try await backend.terminate(reference) + _ = try await execute("rm -f '\(marker)'") + await channel.disconnect() + await control.disconnect() + } + + @Test(.timeLimit(.minutes(1))) + func reattachmentLeavesVimAndRunningProcessesUntouched() async throws { + let fixture = try loadFixture() + let hostID = UUID() + let coordinator = RemoteSessionCoordinator() + let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await control.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let token = UUID() + let session = try await backend.create(token: token, startupCommand: nil, shellIntegration: true) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: hostID, endpoint: "localhost:\(fixture.port)", + username: "root", ownershipToken: token) + await MainActor.run { coordinator.attachment = reference } + return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command) + } + try await connect(control, fixture: fixture, hostID: hostID) + let reference = try #require(coordinator.attachment) + try await control.releaseTerminalForControl() + let execute = try await control.sessionCommandExecutor() + let target = try RemoteTmuxCommands.quote(reference.session.id) + _ = try await execute("tmux send-keys -t \(target) 'vim -n -u NONE -i NONE' Enter") + var running = "" + for _ in 0..<100 { + running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout + if running.contains("vim") { break } + try await Task.sleep(for: .milliseconds(10)) + } + try #require(running.contains("vim")) + let vimBefore = try await execute("tmux capture-pane -p -t \(target)").stdout + let client = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await client.configureStartup { execute in + try await coordinator.prepare( + preferences: .init(enabled: true, onConnect: .createManaged), registry: .live, execute: execute, + hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root", + startupCommand: "echo MUST_NOT_REPLAY", requiresNewSession: false, shellIntegration: true) + } + try await connect(client, fixture: fixture, hostID: hostID) + let vimAfter = try await execute("tmux capture-pane -p -t \(target)").stdout + #expect(vimAfter == vimBefore) + await client.write(Data([0x02, 0x64])) + let backend = RemoteTmuxService(execute: execute) + var detached = false + for _ in 0..<100 { + detached = try await backend.sessions().contains { $0.id == reference.session.id && $0.clients == 0 } + if detached { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(detached) + try await connect(client, fixture: fixture, hostID: hostID) + let vimReattached = try await execute("tmux capture-pane -p -t \(target)").stdout + #expect(vimReattached == vimBefore) + _ = try await execute("tmux send-keys -t \(target) Escape ':q!' Enter") + for _ in 0..<100 { + running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout + if !running.contains("vim") { break } + try await Task.sleep(for: .milliseconds(10)) + } + _ = try await execute("tmux send-keys -t \(target) 'sleep 120' Enter") + for _ in 0..<100 { + running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout + if running == "sleep\n" { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(running == "sleep\n") + let panePID = try await execute("tmux display-message -p -t \(target) '#{pane_pid}'").stdout + .trimmingCharacters(in: .whitespacesAndNewlines) + let pid = try #require(UInt64(panePID)) + let child = try await execute("pgrep -P \(pid) -x sleep").stdout.trimmingCharacters(in: .whitespacesAndNewlines) + let jobPID = try #require(UInt64(child)) + await client.disconnect() + try await connect(client, fixture: fixture, hostID: hostID) + running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout + #expect(running == "sleep\n") + try await backend.terminate(reference) + var alive = true + for _ in 0..<100 { + let states = try await execute("ps -o stat= -p \(pid),\(jobPID)").stdout + // A minimal container's PID 1 may retain zombies. Neither the shell nor + // its foreground job may remain executable after owned termination. + alive = states.split(whereSeparator: \.isWhitespace).contains { !$0.hasPrefix("Z") && !$0.hasPrefix("X") } + if !alive { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(!alive) + await client.disconnect() + await control.disconnect() + } + + private func loadFixture() throws -> Fixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"]) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + } + + private final class ConnectionState { + var serverPID: UInt64? + var exit: Result? + } + + private func connect(_ channel: SSHPTYChannel, fixture: Fixture, hostID: UUID) async throws { + let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8) + try await channel.connect( + using: SSHConnectionRequest( + configuration: .init( + hostID: hostID, host: "127.0.0.1", port: fixture.port, username: "root", + credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])), + shellIntegrationEnabled: false, columns: 80, rows: 24) + } +} diff --git a/TFMTests/ShellIntegrationBootstrapTests.swift b/TFMTests/ShellIntegrationBootstrapTests.swift index 5e54d3fd..d68fa13d 100644 --- a/TFMTests/ShellIntegrationBootstrapTests.swift +++ b/TFMTests/ShellIntegrationBootstrapTests.swift @@ -30,7 +30,7 @@ struct ShellIntegrationBootstrapTests { #expect(commandText.contains("__termind_get_trap")) #expect(commandText.contains("__termind_integration_token")) #expect(commandText.contains("__termind_compose_clear")) - #expect(commandText.contains("emacs-standard vi-insertion vi-command")) + #expect(commandText.contains("emacs-standard vi-insert vi-command")) #expect(commandText.contains("\\e[777;1~")) #expect(commandText.contains("\\e[777;2~")) #expect(!commandText.contains("$HOSTNAME")) @@ -239,6 +239,21 @@ struct ShellIntegrationBootstrapTests { try assertInstallationSyntax(shell: "zsh", executable: "/bin/zsh") } + @Test(arguments: ["emacs", "vi"], [false, true]) + func bashDraftBindingsInstallWithoutKeymapErrors(editingMode: String, runtime: Bool) throws { + let bootstrap = RemoteShellIntegrationBootstrap(token: "KEYMAP") + var script = Data("set -o \(editingMode)\n__termind_event() { :; }\n".utf8) + script.append(try #require(bootstrap.installationCommand(for: "bash", runtime: runtime))) + script.append(Data("builtin trap - DEBUG\nexit 0\n".utf8)) + + let result = try runShell( + executable: "/bin/bash", arguments: ["--noprofile", "--norc"], input: script + ) + + #expect(result.status == 0, Comment(rawValue: result.errors)) + #expect(result.errors.isEmpty, Comment(rawValue: result.errors)) + } + @Test func bashPreservesExistingHooksAndReportsACommandFailure() throws { var bootstrap = RemoteShellIntegrationBootstrap(token: "RUNTIME") diff --git a/TFMTests/TerminalContextRuntimeTests.swift b/TFMTests/TerminalContextRuntimeTests.swift new file mode 100644 index 00000000..2403074a --- /dev/null +++ b/TFMTests/TerminalContextRuntimeTests.swift @@ -0,0 +1,394 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil), .serialized) +@MainActor +struct TerminalContextRuntimeTests { + private struct Fixture: Decodable { + let port: Int + let hostFingerprint: String + let privateKeyPath: String + let maxSessions: Int? + } + + private final class State { + let coordinator = TerminalContextCoordinator() + var events: [TerminalContextEvent] = [] + var output = Data() + var reference: RemoteSessionReference? + var directoryInvalidations = 0 + + func receive(_ event: SSHShellIntegrationEvent) { + if case .runtime(let update) = event { + let hadDirectory = coordinator.directory != nil + events += coordinator.receive(update, generation: coordinator.generation) + if hadDirectory, coordinator.directory == nil { directoryInvalidations += 1 } + } + } + } + + @Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"]) + func managedContextsDraftsAndInvisibleCompletions(shell: String) async throws { + let fixture = try loadFixture() + guard (fixture.maxSessions ?? 1) > 1 else { return } + let state = State() + let channel = makeChannel(state) + var stage = "connect" + await channel.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let owner = UUID() + let session = try await backend.create( + token: owner, startupCommand: "cd /tmp", shellIntegration: true, + environment: [ + .init(variable: "SHELL", value: "/bin/\(shell)"), + .init(variable: "LC_ALL", value: "C.UTF-8"), + ]) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root", + ownershipToken: owner) + await MainActor.run { state.reference = reference } + return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command) + } + do { + try await connect(channel, fixture) + stage = "initial prompt" + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true } + let first = try #require(state.coordinator.active) + #expect(first.shell == shell) + #expect(first.directory == "/tmp") + let execute = try await channel.runtimeCommandExecutor() + stage = "read draft" + let beforeInput = state.coordinator.revision + let invalidations = state.directoryInvalidations + state.coordinator.noteInput() + await channel.write(Data("echo 'draft | 中文'".utf8)) + try await wait { + state.coordinator.revision > beforeInput && state.coordinator.active?.identity == first.identity + } + let draftContext = try #require(state.coordinator.active) + #expect(try await channel.contextDraft(draftContext) == "echo 'draft | 中文'") + try await wait { (state.coordinator.active?.sequence ?? 0) > draftContext.sequence } + #expect(state.coordinator.directory == "/tmp") + #expect(state.directoryInvalidations == invalidations) + stage = "prefix mode" + state.coordinator.noteInput() + await channel.write(Data([0x02])) + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == false } + #expect(state.coordinator.directory == "/tmp") + #expect(state.directoryInvalidations == invalidations) + state.coordinator.noteInput() + await channel.write(Data([0x1B])) + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true } + #expect(state.directoryInvalidations == invalidations) + let resumedContext = try #require(state.coordinator.active) + #expect(try await channel.contextDraft(resumedContext) == "echo 'draft | 中文'") + try await wait { (state.coordinator.active?.sequence ?? 0) > resumedContext.sequence } + stage = "replace draft" + let replacementContext = try #require(state.coordinator.active) + #expect( + try await channel.contextDraft(replacementContext, replacing: "printf 'replacement'") + == "printf 'replacement'") + try await wait { (state.coordinator.active?.sequence ?? 0) > replacementContext.sequence } + let editedElsewhere = try #require(state.coordinator.active) + _ = try await execute("tmux send-keys -t '\(first.identity.pane)' -l 'remote-edit'") + do { + _ = try await channel.contextDraft(editedElsewhere, replacing: "MUST_NOT_REPLACE_REMOTE_EDIT") + Issue.record("A draft changed by another client must not be replaced") + } catch {} + let beforeClear = state.coordinator.revision + state.coordinator.noteInput() + await channel.write(Data([0x15])) + try await wait { + state.coordinator.revision > beforeClear && state.coordinator.active?.identity == first.identity + } + stage = "split pane" + let target = try RemoteTmuxCommands.quote(first.identity.pane) + let split = try await execute("tmux split-window -h -P -F '#{pane_id}' -t \(target)") + let second = split.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(RemoteShellContextRecord.validPane(second)) + try await wait { + state.coordinator.active?.identity.pane == second && state.coordinator.active?.promptReady == true + } + #expect(state.coordinator.contexts.count == 2) + stage = "background command" + _ = try await execute("tmux send-keys -t \(target) 'sleep 2; false' Enter") + try await wait { + state.events.contains { + $0.context == first.identity + && { + if case .commandStarted = $0.payload { return true } + return false + }($0) + } + } + try await wait { + state.events.contains { + $0.context == first.identity + && { + if case .commandFinished(1, _) = $0.payload { return true } + return false + }($0) + } + } + #expect(state.coordinator.active?.identity.pane == second) + stage = "unsafe modes" + _ = try await execute("tmux copy-mode -t '\(second)'") + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == false } + #expect(state.coordinator.directory == nil) + _ = try await execute( + "tmux send-keys -t '\(second)' -X cancel; tmux set-window-option -t '\(second)' synchronize-panes on") + try await Task.sleep(for: .seconds(1)) + #expect(state.coordinator.active?.capabilities.contains(.replaceDraft) == false) + _ = try await execute("tmux set-window-option -t '\(second)' synchronize-panes off") + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true } + let terminal = try #require(state.coordinator.clientIdentity?.components(separatedBy: ":").first) + let client = try RemoteTmuxCommands.quote(terminal) + _ = try await execute("tmux refresh-client -t \(client) -f active-pane") + try await wait { state.coordinator.status == .unavailable } + _ = try await execute("tmux refresh-client -t \(client) -f '!active-pane'") + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true } + let stale = try #require(state.coordinator.active) + _ = try await execute("tmux select-pane -t \(target)") + do { + _ = try await channel.contextDraft(stale, replacing: "MUST_NOT_APPEAR") + Issue.record("A silently switched pane must reject the old draft target") + } catch {} + #expect( + try await execute("tmux capture-pane -p -t '\(second)'").stdout.contains("MUST_NOT_APPEAR") == false) + stage = "window switch" + let sessionTarget = try RemoteTmuxCommands.quote(state.reference!.session.id) + let window = try await execute("tmux new-window -t \(sessionTarget) -P -F '#{pane_id}'") + let third = window.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(RemoteShellContextRecord.validPane(third)) + try await wait { + state.coordinator.active?.identity.pane == third && state.coordinator.active?.promptReady == true + } + _ = try await execute("tmux send-keys -t '\(third)' 'cd /' Enter") + try await wait { state.coordinator.directory == "/" } + _ = try await execute("tmux select-window -t \(target); tmux select-pane -t \(target)") + try await wait { + state.coordinator.active?.identity == first.identity && state.coordinator.directory == "/tmp" + } + stage = "reconnect without hook reinstallation" + let reference = try #require(state.reference) + await channel.disconnect() + state.coordinator.reset() + state.events.removeAll() + await channel.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + try backend.validate(reference) + return try SSHPTYStartupPlan(command: backend.launch(reference, created: false).command) + } + try await connect(channel, fixture) + try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == true } + #expect(state.coordinator.contexts[first.identity] != nil) + #expect(state.events.isEmpty) + try await cleanup(channel, state) + } catch { + Issue.record("Runtime acceptance failed at \(stage) for \(shell): \(error)") + try? await cleanup(channel, state) + throw error + } + } + + @Test(.timeLimit(.minutes(1)), arguments: ["bash", "zsh", "fish"]) + func ordinaryShellManualAttachAndDetachUseTheSameTransport(shell: String) async throws { + let fixture = try loadFixture() + guard (fixture.maxSessions ?? 1) > 1 else { return } + let state = State() + let channel = makeChannel(state) + await channel.configureEnvironment([ + .init(variable: "TERMIND_TMUX_SHELL", value: "/bin/\(shell)"), .init(variable: "LC_ALL", value: "C.UTF-8"), + ]) + do { + try await connect(channel, fixture) + try await wait { state.coordinator.status == .ordinary } + let outer = try #require(state.coordinator.active) + #expect(outer.shell == shell) + await channel.write(Data("echo scratch".utf8)) + #expect(try await channel.contextDraft(outer) == "echo scratch") + await channel.write(Data([0x15])) + let execute = try await channel.runtimeCommandExecutor() + let backend = RemoteTmuxService(execute: execute) + let owner = UUID() + let session = try await backend.create(token: owner, startupCommand: "cd /tmp", shellIntegration: true) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root", + ownershipToken: owner) + state.reference = reference + await channel.write(Data("tmux attach-session -t '\(session.id)'\n".utf8)) + try await wait { state.coordinator.status == .multiplexer && state.coordinator.active?.promptReady == true } + #expect(state.coordinator.directory == "/tmp") + await channel.write(Data([0x02, 0x64])) + try await wait { state.coordinator.status == .ordinary } + #expect(state.coordinator.active?.identity == outer.identity) + #expect( + state.events.contains { + if case .commandFinished = $0.payload { return $0.context == outer.identity } + return false + }) + try await cleanup(channel, state) + } catch { + try? await cleanup(channel, state) + throw error + } + } + + @Test(.timeLimit(.minutes(1))) + func capacityFailureLeavesTheTerminalUsable() async throws { + let fixture = try loadFixture() + let state = State() + let channel = SSHPTYChannel( + output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in }, + shellIntegration: { event in Task { @MainActor in state.receive(event) } }, + maxSessionsPolicy: .init(preferences: .init(maxSessions: 1))) + do { + try await connect(channel, fixture) + try await wait { state.coordinator.status == .ordinary } + let context = try #require(state.coordinator.active) + do { + _ = try await channel.contextDraft(context) + Issue.record("The occupied channel budget must refuse a draft exec") + } catch {} + await channel.write(Data("printf 'TERMINAL_STILL_WORKS\\n'\n".utf8)) + try await wait { String(decoding: state.output, as: UTF8.self).contains("TERMINAL_STILL_WORKS\r\n") } + await channel.disconnect() + } catch { + await channel.disconnect() + throw error + } + } + + @Test(.timeLimit(.minutes(1))) + func serverChannelRefusalLeavesTerminalUsable() async throws { + let fixture = try loadFixture() + guard (fixture.maxSessions ?? 1) == 1 else { return } + let state = State() + let channel = makeChannel(state) + await channel.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let owner = UUID() + let session = try await backend.create(token: owner, startupCommand: nil, shellIntegration: true) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root", + ownershipToken: owner) + await MainActor.run { state.reference = reference } + return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command) + } + do { + try await connect(channel, fixture) + try await wait { state.coordinator.status == .unavailable } + #expect(state.coordinator.directory == nil) + #expect(state.coordinator.ticket(requiring: .replaceDraft) == nil) + await channel.write(Data("printf 'SERVER_LIMIT_%s\\n' TERMINAL_OK\n".utf8)) + try await wait { String(decoding: state.output, as: UTF8.self).contains("SERVER_LIMIT_TERMINAL_OK") } + try await cleanup(channel, state) + } catch { + try? await cleanup(channel, state) + throw error + } + } + + @Test(.timeLimit(.minutes(1))) + func loginScriptAutoAttachNeverReceivesBootstrapInput() async throws { + let fixture = try loadFixture() + guard (fixture.maxSessions ?? 1) > 1 else { return } + let state = State() + let channel = makeChannel(state) + let backup = "/tmp/termind-profile-\(UUID().uuidString)" + let restore = """ + if [ -f '\(backup)' ]; then mv '\(backup)' /root/.bash_profile; + else rm -f /root/.bash_profile; fi + """ + await channel.configureStartup { execute in + let backend = RemoteTmuxService(execute: execute) + let owner = UUID() + let session = try await backend.create( + token: owner, startupCommand: "exec vim -n -u NONE -i NONE", shellIntegration: true, + environment: [.init(variable: "SHELL", value: "/bin/bash")]) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root", + ownershipToken: owner) + await MainActor.run { state.reference = reference } + let profile = try RemoteTmuxCommands.quote("exec tmux attach-session -t '\(session.id)'") + _ = try await execute( + "[ ! -f /root/.bash_profile ] || cp /root/.bash_profile '\(backup)'; printf '%s\\n' \(profile) > /root/.bash_profile" + ) + return nil + } + do { + try await connect(channel, fixture) + let execute = try await channel.runtimeCommandExecutor() + let reference = try #require(state.reference) + let target = try RemoteTmuxCommands.quote(reference.session.id) + var attached = false + for _ in 0..<100 { + attached = + try await execute("tmux list-clients -t \(target) -F '#{client_pid}'").stdout + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + if attached { break } + try await Task.sleep(for: .milliseconds(25)) + } + _ = try await execute(restore) + try #require(attached) + try await Task.sleep(for: .seconds(1)) + let before = try await execute("tmux capture-pane -p -t \(target)").stdout + try await Task.sleep(for: .seconds(2)) + let after = try await execute("tmux capture-pane -p -t \(target)").stdout + #expect(before == after) + #expect(!after.contains("__termind_")) + #expect( + try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout == "vim\n") + #expect(state.coordinator.directory == nil) + #expect(state.coordinator.ticket(requiring: .replaceDraft) == nil) + try await cleanup(channel, state) + } catch { + if let execute = try? await channel.sessionCommandExecutor() { _ = try? await execute(restore) } + try? await cleanup(channel, state) + throw error + } + } + + private func makeChannel(_ state: State) -> SSHPTYChannel { + SSHPTYChannel( + output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in }, + shellIntegration: { event in Task { @MainActor in state.receive(event) } }) + } + + private func cleanup(_ channel: SSHPTYChannel, _ state: State) async throws { + try await channel.releaseTerminalForControl() + if let reference = state.reference { + let execute = try await channel.sessionCommandExecutor() + try await RemoteTmuxService(execute: execute).terminate(reference) + } + await channel.disconnect() + } + + private func loadFixture() throws -> Fixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"]) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + } + + private func wait(_ condition: () -> Bool) async throws { + for _ in 0..<400 { + if condition() { return } + try await Task.sleep(for: .milliseconds(25)) + } + throw TerminalRuntimeError.invalidResponse + } + + private func connect(_ channel: SSHPTYChannel, _ fixture: Fixture) async throws { + let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8) + try await channel.connect( + using: SSHConnectionRequest( + configuration: .init( + hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: "root", + credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])), + shellIntegrationEnabled: true, columns: 100, rows: 30) + } +} diff --git a/TFMTests/TerminalContextWireTests.swift b/TFMTests/TerminalContextWireTests.swift new file mode 100644 index 00000000..933b9698 --- /dev/null +++ b/TFMTests/TerminalContextWireTests.swift @@ -0,0 +1,151 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct TerminalContextWireTests { + private let token = "4EA6E90A-81DA-4879-ACAF-D91CA84BD817" + + @Test func managedNamesAreCompactAndNeverGrantOwnershipByName() throws { + let owner = try #require(UUID(uuidString: token)) + let name = RemoteTmuxCommands.managedSessionName(token: owner) + #expect(name.hasPrefix("termind-")) + #expect(name.utf8.count == 24) + #expect(!name.contains(owner.uuidString)) + #expect(RemoteTmuxCommands.managedSessionName(token: owner) == name) + #expect(RemoteTmuxCommands.managedSessionName(token: UUID()) != name) + let command = try RemoteTmuxCommands.create(token: owner, startupCommand: nil, shellIntegration: false) + #expect(command.contains("-s '\(name)'")) + #expect(command.contains("TERMIND_TMUX_OWNER=\(owner.uuidString)' ] || exit 79")) + } + + private func record(pane: String = "%1", sequence: Int = 2) -> String { + """ + M1|\(token)|\(pane)|ctx.ABCDEF|bash|%2Fhome%2Ftest|123 + R1|\(sequence)|1|%2Ftmp%2Fa%7Cb + E1|1|directory|%2Ftmp%2Fa%7Cb| + E1|2|prompt|| + + """ + } + + @Test func recordDecodesNamesAsDataAndRejectsBrokenSequences() throws { + let parsed = try RemoteShellContextRecord(record()) + #expect(parsed.directory == "/tmp/a|b") + #expect(try RemoteShellContextRecord(record().replacingOccurrences(of: "\n", with: "\r\n")).ready) + #expect(throws: (any Error).self) { try RemoteShellContextRecord(record(sequence: 1)) } + #expect(throws: (any Error).self) { try RemoteShellContextRecord(record(pane: "%1;touch /tmp/x")) } + } + + @Test func activePaneIsBoundToTheClientAndUninstrumentedPanesHaveNoDraftCapability() throws { + let prefix = """ + tmux 3.3a + C|/dev/pts/1|555|100|777|$0|99|%2||root + P|%1|123|0|0|bash + P|%2|456|0|0|unknown + BEGIN + """ + let text = prefix + "\n" + record() + "END\nDONE\n" + let update = try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/1") + #expect(update.active?.pane == "%2") + #expect(update.contexts.first { $0.identity == update.active }?.capabilities.isEmpty == true) + #expect(throws: (any Error).self) { + try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/other") + } + let independent = text.replacingOccurrences(of: "|%2||root", with: "|%2|active-pane|root") + #expect(try RemoteTmuxRuntimeAdapter.parse(independent, terminal: "/dev/pts/1").status == .unavailable) + } + + @Test func connectionScopedFramesSurviveEverySplitWithoutLeakingControlText() throws { + let frame = Data("\u{1B}]777;TermindContext=\(token);\(record(pane: "shell"))\u{7}".utf8) + for split in 0...frame.count { + var filter = RemoteTerminalContextFilter(token: token) + let first = filter.receive(Data(frame.prefix(split))) + let second = filter.receive(Data(frame.dropFirst(split))) + #expect(first.output.isEmpty && second.output.isEmpty) + let updates = first.contexts + second.contexts + #expect(updates.count == 1) + #expect(updates.first?.active?.pane == "shell") + } + } + + @Test func runtimeBudgetNeverQueuesBehindATerminal() async throws { + let budget = SSHChannelBudget(maximum: 1) + let terminal = try await budget.acquire() + #expect(budget.tryAcquire() == nil) + terminal.release() + let observation = try #require(budget.tryAcquire()) + observation.release() + budget.close() + #expect(budget.tryAcquire() == nil) + } + + @Test func malformedAndOversizedFramesCannotEnableCapabilities() throws { + var filter = RemoteTerminalContextFilter(token: token) + let bad = record(pane: "shell").replacingOccurrences(of: "%2Ftmp", with: "%GGtmp") + let update = filter.receive(Data("\u{1B}]777;TermindContext=\(token);\(bad)\u{7}".utf8)) + #expect(update.contexts.map(\.status) == [.unavailable]) + #expect(update.output.isEmpty) + let oversized = Data( + ("\u{1B}]777;TermindContext=\(token);" + String(repeating: "x", count: 70_000) + "\u{7}visible").utf8) + let large = filter.receive(oversized) + #expect(large.contexts.map(\.status) == [.unavailable]) + #expect(String(decoding: large.output, as: UTF8.self) == "visible") + } + + @Test func shellRestartAndUnsafeModesCannotReuseTheOldPrompt() throws { + let base = """ + tmux 3.3a + C|/dev/pts/1|555|100|777|$0|99|%1||root + P|%1|123|0|0|bash + BEGIN + """ + let text = base + "\n" + record() + "END\nDONE\n" + for unsafe in [ + text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|1|0|bash"), + text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|0|1|bash"), + text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|456|0|0|bash"), + text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|0|0|unknown"), + ] { + let update = try RemoteTmuxRuntimeAdapter.parse(unsafe, terminal: "/dev/pts/1") + let active = try #require(update.contexts.first { $0.identity == update.active }) + #expect(!active.capabilities.contains(.directory)) + #expect(!active.capabilities.contains(.replaceDraft)) + } + } + + @Test @MainActor + func prefixModePreservesDirectoryButInvalidatesDraftOperations() throws { + let header = """ + tmux 3.3a + C|/dev/pts/1|555|100|777|$0|99|%1||root + P|%1|123|0|0|bash + BEGIN + """ + let text = header + "\n" + record() + "END\nDONE\n" + let coordinator = TerminalContextCoordinator() + let ordinary = try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/1") + coordinator.receive(ordinary, generation: coordinator.generation) + let draft = try #require(coordinator.ticket(requiring: .replaceDraft)) + let identity = coordinator.active?.identity + let prefix = try RemoteTmuxRuntimeAdapter.parse( + text.replacingOccurrences(of: "|%1||root", with: "|%1||prefix"), terminal: "/dev/pts/1") + + coordinator.receive(prefix, generation: coordinator.generation) + + #expect(coordinator.active?.identity == identity) + #expect(coordinator.directory == "/tmp/a|b") + #expect(coordinator.ticket(requiring: .directory) != nil) + #expect(coordinator.ticket(requiring: .readDraft) == nil) + #expect(coordinator.ticket(requiring: .replaceDraft) == nil) + #expect(!coordinator.isCurrent(draft, requiring: .replaceDraft)) + + coordinator.receive(ordinary, generation: coordinator.generation) + #expect(coordinator.directory == "/tmp/a|b") + #expect(coordinator.ticket(requiring: [.readDraft, .replaceDraft]) != nil) + #expect(!coordinator.isCurrent(draft, requiring: .replaceDraft)) + } +} diff --git a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift index 342db1e2..5a1d8227 100644 --- a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift +++ b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift @@ -3,14 +3,34 @@ import Foundation /// One activity summarizes all mobile workspaces. Never contains credentials or terminal output. nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { + struct Recovery: Codable, Hashable { + var version = 1 + var sourceWorkspaceID: UUID + var hostID: UUID + var kind: String + /// Opaque, versioned domain reference. Never included in deep links. + var remoteSession: Data? + } + struct Session: Codable, Hashable, Identifiable { var id: UUID var title: String var kind: String var connected: Bool var iconAsset: String? = nil + var recovery: Recovery? = nil - var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) } + var destinationURL: URL { + guard let recovery else { return WorkspaceActivityAttributes.destinationURL(for: id) } + var components = URLComponents( + url: WorkspaceActivityAttributes.destinationURL(for: id), resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "v", value: "1"), + URLQueryItem(name: "host", value: recovery.hostID.uuidString), + URLQueryItem(name: "kind", value: recovery.kind), + ] + return components.url! + } } struct ContentState: Codable, Hashable { @@ -31,13 +51,23 @@ nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { session.title = String(decoding: String(session.title.prefix(32)).utf8.prefix(96), as: UTF8.self) return session } - return Self( + var result = Self( workspaceCount: workspaces.count, connectedCount: workspaces.filter(\.connected).count, sessions: visible, focusedWorkspaceID: focused?.id) + // Leave room for Activity attributes and system encoding overhead. Drop whole entries, + // never part of an identity. The focused entry is first and therefore retained longest. + while !result.sessions.isEmpty, (try? JSONEncoder().encode(result).count) ?? Int.max > 3500 { + result.sessions.removeLast() + } + if !result.sessions.contains(where: { $0.id == result.focusedWorkspaceID }) { + result.focusedWorkspaceID = result.sessions.first?.id + } + return result } var destinationURL: URL { - focusedWorkspaceID.map(WorkspaceActivityAttributes.destinationURL(for:)) + sessions.first(where: { $0.id == focusedWorkspaceID })?.destinationURL + ?? focusedWorkspaceID.map(WorkspaceActivityAttributes.destinationURL(for:)) ?? WorkspaceActivityAttributes.destinationURL } } @@ -57,8 +87,25 @@ nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { static func workspaceID(from url: URL) -> UUID? { guard url.scheme == "termind", url.host == "workspace", - url.query == nil, url.fragment == nil, url.pathComponents.count == 2 + url.user == nil, url.password == nil, url.port == nil, + url.fragment == nil, url.pathComponents.count == 2 else { return nil } + if url.query != nil, recovery(from: url) == nil { return nil } return UUID(uuidString: url.lastPathComponent) } + + static func recovery(from url: URL) -> Recovery? { + guard url.scheme == "termind", url.host == "workspace", + url.user == nil, url.password == nil, url.port == nil, url.fragment == nil, + url.pathComponents.count == 2, let id = UUID(uuidString: url.lastPathComponent), + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + items.count == 3, Set(items.map(\.name)) == ["v", "host", "kind"], + items.first(where: { $0.name == "v" })?.value == "1", + 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) + else { return nil } + return Recovery(sourceWorkspaceID: id, hostID: host, kind: kind) + } }