From 8e3f1128dacd369fe3e0e384d55f64036a897d68 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 01:04:12 +0800 Subject: [PATCH] feat(sessions): add tmux persistence and terminal context tracking Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection. Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts. --- .../SSHAgentForwardingPersistenceTests.swift | 6 +- FrameworkTests/SessionPersistenceTests.swift | 592 +++++++++ .../TerminalContextCoordinatorTests.swift | 163 +++ Frameworks/Resources/Localizable.xcstrings | 1175 +++++++++++++++++ .../Connections/ConnectionJourney.swift | 9 + .../FeatureModels/HostLibraryModel.swift | 2 + .../Terminal/RemoteSessionBackend.swift | 371 ++++++ .../RemoteSessionCloseCoordinator.swift | 84 ++ .../Terminal/TerminalContextCoordinator.swift | 151 +++ .../TerminalCommandMonitor.swift | 29 +- Frameworks/TermindDomain/Hosts/Host.swift | 10 +- .../TermindDomain/Hosts/TailscaleHost.swift | 7 +- .../Sessions/SSHShellIntegrationEvent.swift | 1 + .../Sessions/SessionPersistence.swift | 77 ++ .../Sessions/TerminalContext.swift | 93 ++ .../Vault/SyncRecordFormats.swift | 4 +- .../TermindPersistence/HostPersistence.swift | 13 +- .../TermindPersistence.swift | 2 +- .../TermindSchemaV53HostModels.swift | 136 ++ .../VaultSyncPersistence.swift | 18 + .../TermindPersistence/VaultWireV1.swift | 2 +- .../TermindPersistence/VaultWireV3.swift | 104 +- .../TermindPersistence/VaultWireV4.swift | 102 ++ .../ConnectionJourneySections.swift | 3 +- .../HostIntegrationSection.swift | 34 + .../RemoteSessionPromptView.swift | 155 +++ .../Strings/Connections.swift | 4 +- .../Strings/Presentation.swift | 8 + .../Strings/SessionPersistence.swift | 42 + .../SSH/RemoteShellContextJournal.swift | 227 ++++ .../SSH/RemoteTerminalContextFilter.swift | 83 ++ .../SSH/RemoteTerminalDraft.swift | 102 ++ .../SSH/RemoteTmuxRuntimeAdapter.swift | 143 ++ .../SSH/RemoteTmuxService.swift | 278 ++++ .../SSH/RemoteTmuxShellStartup.swift | 100 ++ .../SSH/SSHMaxSessionsPolicy.swift | 11 + .../SSH/SSHMultiplexedExecChannelOwner.swift | 57 +- .../SSH/SSHPTYChannel.swift | 662 +++++++++- Scripts/ssh-tmux-session-fixture.py | 130 ++ TFI/App/MobileAppDependencies.swift | 1 - TFI/App/MobileAppModel.swift | 166 ++- TFI/App/MobileRecentlyClosedWorkspaces.swift | 6 +- TFI/App/MobileRootView.swift | 4 + TFI/App/MobileSceneModel.swift | 38 +- TFI/App/MobileWorkspaceSessionModel.swift | 71 +- .../HostEditor/MobileHostEditor.swift | 1 + TFI/Features/Workspaces/MobileWorkspace.swift | 267 +++- .../Workspaces/MobileWorkspaceView.swift | 14 +- TFI/Infrastructure/MobileWorkspaceStore.swift | 78 +- .../MobileLiveActivityController.swift | 25 +- TFI/Platform/MobileLiveActivityServices.swift | 6 + .../MobileConnectionJourneyView.swift | 4 + TFITests/MobileAdaptiveLayoutTests.swift | 16 +- TFITests/MobileAppTests.swift | 468 ++----- ...MobileCommandGeneratorAndUploadTests.swift | 28 + .../MobileLiveActivityRestorationTests.swift | 142 +- TFITests/MobileWorkspaceSessionTests.swift | 132 +- TFITests/MobileWorkspaceStoreTests.swift | 150 +-- .../RemoteSessionSelectionLayoutTests.swift | 52 + TFM/App/AppModel.swift | 2 +- TFM/App/RootView.swift | 4 + TFM/Features/HostEditor/HostEditorView.swift | 1 + TFM/Features/SFTP/SFTPChannelModel.swift | 2 +- TFM/Features/Terminal/SSHSessionModel.swift | 212 ++- .../Terminal/TerminalPlaceholderView.swift | 6 +- .../Terminal/TerminalUploadModel.swift | 20 +- .../Workspaces/ClosedWorkspaceRecord.swift | 5 + TFM/Features/Workspaces/Pane.swift | 1 + TFM/Features/Workspaces/WindowModel.swift | 92 +- TFM/Platform/WorkspaceCloseConfirmation.swift | 15 + TFM/UI/Components/ConnectionJourneyView.swift | 8 +- TFMTests/AppLanguageTests.swift | 35 + TFMTests/ArchitectureBoundaryTests.swift | 20 + .../HostShellIntegrationSessionTests.swift | 28 + TFMTests/RemoteSessionWireTests.swift | 362 +++++ TFMTests/ShellIntegrationBootstrapTests.swift | 17 +- TFMTests/TerminalContextRuntimeTests.swift | 394 ++++++ TFMTests/TerminalContextWireTests.swift | 151 +++ .../WorkspaceActivityAttributes.swift | 55 +- 79 files changed, 7345 insertions(+), 944 deletions(-) create mode 100644 FrameworkTests/SessionPersistenceTests.swift create mode 100644 FrameworkTests/TerminalContextCoordinatorTests.swift create mode 100644 Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift create mode 100644 Frameworks/TermindApplication/Terminal/RemoteSessionCloseCoordinator.swift create mode 100644 Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift create mode 100644 Frameworks/TermindDomain/Sessions/SessionPersistence.swift create mode 100644 Frameworks/TermindDomain/Sessions/TerminalContext.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV53HostModels.swift create mode 100644 Frameworks/TermindPersistence/VaultWireV4.swift create mode 100644 Frameworks/TermindPresentation/RemoteSessionPromptView.swift create mode 100644 Frameworks/TermindPresentation/Strings/SessionPersistence.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxService.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift create mode 100644 Scripts/ssh-tmux-session-fixture.py create mode 100644 TFITests/RemoteSessionSelectionLayoutTests.swift create mode 100644 TFMTests/RemoteSessionWireTests.swift create mode 100644 TFMTests/TerminalContextRuntimeTests.swift create mode 100644 TFMTests/TerminalContextWireTests.swift 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) + } }