From d92318a2bb5eb4f77947dcbe8d1f261c0523494f Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 22:32:31 +0800 Subject: [PATCH] fix(terminal): restore multiplexer paths and silent session close --- .../NativeTerminalContextTests.swift | 253 ++++++++++ FrameworkTests/RemoteMultiplexerTests.swift | 10 +- FrameworkTests/SessionPersistenceTests.swift | 57 +-- Frameworks/Resources/Localizable.xcstrings | 440 ------------------ .../RemoteSessionCloseCoordinator.swift | 56 +-- .../Terminal/TerminalContextCoordinator.swift | 9 + .../Sessions/TerminalContext.swift | 21 +- .../RemoteSessionPromptView.swift | 38 -- .../Strings/SessionPersistence.swift | 9 - .../Strings/Workspace.swift | 20 - .../SSH/RemoteHerdrCommands.swift | 14 +- .../SSH/RemoteMultiplexerDialect.swift | 3 +- .../SSH/RemoteMultiplexerRuntimeAdapter.swift | 82 +++- .../SSH/RemoteNativeMultiplexerRuntime.swift | 215 +++++++++ .../SSH/RemoteTerminalContextFilter.swift | 3 +- .../SSH/RemoteTerminalProcessProbe.swift | 234 ++++++++++ .../SSH/RemoteTmuxRuntimeDialect.swift | 37 +- .../SSH/RemoteZellijCommands.swift | 17 +- .../SSH/RemoteZmxCommands.swift | 13 +- Scripts/ssh-tmux-session-fixture.py | 33 ++ TFI/App/MobileRootView.swift | 2 +- TFI/Features/Workspaces/MobileWorkspace.swift | 10 +- TFM/App/RootView.swift | 2 +- TFM/App/TermindApp.swift | 5 - TFM/Features/StatusBar/StatusBarView.swift | 9 +- TFM/Features/Terminal/SSHSessionModel.swift | 14 + TFM/Platform/WorkspaceCloseConfirmation.swift | 44 +- TFMTests/NativeMultiplexerRuntimeTests.swift | 384 +++++++++++++++ .../WorkspaceCloseConfirmationTests.swift | 90 ++-- 29 files changed, 1403 insertions(+), 721 deletions(-) create mode 100644 FrameworkTests/NativeTerminalContextTests.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteNativeMultiplexerRuntime.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift create mode 100644 TFMTests/NativeMultiplexerRuntimeTests.swift diff --git a/FrameworkTests/NativeTerminalContextTests.swift b/FrameworkTests/NativeTerminalContextTests.swift new file mode 100644 index 00000000..e2258d99 --- /dev/null +++ b/FrameworkTests/NativeTerminalContextTests.swift @@ -0,0 +1,253 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +nonisolated struct NativeTerminalContextTests { + private static func hex(_ value: String) -> String { + value.utf8.map { String(format: "%02x", $0) }.joined() + } + + private static func client(_ backend: SessionPersistenceBackendID) -> RemoteTerminalProcessProbe.Client { + .init( + backend: backend, process: "42", started: "Fri Oct 16 12:00:00 2026", + endpoint: backend == .herdr ? "/tmp/herdr/herdr-client.sock" : "/tmp/\(backend.rawValue)/work") + } + + private static func frame( + _ backend: SessionPersistenceBackendID, native: String, focus: String = "", directory: String = "" + ) -> String { + let version = backend == .zellij ? "0.45.1" : backend == .zmx ? "0.8.1" : "0.9.5" + return """ + V|\(hex("\(backend.rawValue) \(version)")) + I|1:2:3 + N|\(hex(native)) + F|\(hex(focus)) + D|\(hex(directory)) + DONE + + """ + } + + @Test func linuxSocketPeersBindTheActualSessionRatherThanOtherClientsOrArgv() { + let sockets = """ + u_str ESTAB 0 0 * 100 * 200 users:(("zmx",pid=42,fd=3)) + u_str ESTAB 0 0 /tmp/zmx/work 200 * 100 users:(("zmx",pid=50,fd=4)) + u_str ESTAB 0 0 * 300 * 400 users:(("zmx",pid=142,fd=3)) + u_str ESTAB 0 0 /tmp/zmx/other 400 * 300 users:(("zmx",pid=60,fd=4)) + """ + #expect(RemoteTerminalProcessProbe.linuxEndpoint(sockets, process: "42") == "/tmp/zmx/work") + #expect(RemoteTerminalProcessProbe.linuxEndpoint(sockets, process: "2") == nil) + #expect( + RemoteTerminalProcessProbe.linuxEndpoint( + sockets.replacingOccurrences(of: "200 * 100", with: "200 * 999"), process: "42") == nil) + #expect( + RemoteTerminalProcessProbe.linuxEndpoint( + sockets.replacingOccurrences(of: "pid=142,", with: "pid=42,"), process: "42") == nil) + } + + @Test func darwinUsesTheSocketDeviceAddressNotTheMissingBlockDeviceField() { + let sockets = """ + p50 + f3 + d0x111 + n/tmp/zmx/work + f4 + d0x222 + n/tmp/zmx/work + p42 + f3 + d0x333 + n->0x222 + """ + #expect(RemoteTerminalProcessProbe.darwinEndpoint(sockets, process: "42") == "/tmp/zmx/work") + #expect( + RemoteTerminalProcessProbe.darwinEndpoint( + sockets.replacingOccurrences(of: "->0x222", with: "->0x999"), process: "42") == nil) + } + + @Test func socketDiagnosticEvidenceRequiresABoundedAbsoluteEndpoint() throws { + let client = Self.client(.zellij) + func frame(_ path: String) -> String { + "M|zellij|42|\(Self.hex(client.started))\nP|\(Self.hex(path))\nDONE\n" + } + #expect(try RemoteTerminalProcessProbe.parse(frame(client.endpoint!)) == client) + for path in ["", "relative/session", "/tmp/../session", "/tmp/\0session"] { + #expect(try RemoteTerminalProcessProbe.parse(frame(path))?.endpoint == nil) + } + #expect(throws: RemoteSessionFailure.invalidResponse) { + try RemoteTerminalProcessProbe.parse(frame("/" + String(repeating: "x", count: 262144))) + } + #expect(throws: TerminalRuntimeError.invalidResponse) { + try RemoteTerminalProcessProbe.parse(String(frame(client.endpoint!).dropLast())) + } + } + + @Test(arguments: [SessionPersistenceBackendID.zellij, .zmx, .herdr]) + func missingEndpointDoesNotBecomeAnOrdinaryShell(backend: SessionPersistenceBackendID) async throws { + let client = Self.client(backend) + let response = "M|\(backend.rawValue)|42|\(Self.hex(client.started))\nS|\nDONE\n" + let runtime = RemoteMultiplexerRuntimeAdapter { command in + .init( + stdout: command.contains("ss -xnpH") ? response : "tmux 3.3a\nNONE\n", + stderr: "", exitCode: 0) + } + let update = try await runtime.inspect(terminal: "/dev/pts/1") + #expect(update.status == .multiplexer) + #expect(update.backend == backend) + #expect(update.issue == .clientUnbound) + #expect(update.active == nil) + } + + @Test func unavailableManagedHelperStillFallsBackToTheBoundNativePane() async throws { + let client = Self.client(.zellij) + let token = UUID() + let identity = RemoteMultiplexerIdentity( + kind: .zellij, root: "/tmp/zellij", socket: try #require(client.endpoint), fingerprint: "1:2:3", + process: nil, created: nil, owner: token, helper: "/tmp/termind-shell.testing", isolated: true) + let reference = RemoteSessionReference( + backend: .zellij, + session: .init(id: "managed:work", name: "work", identity: try JSONEncoder().encode(identity)), + hostID: UUID(), endpoint: "fixture", username: "user", ownershipToken: token) + let runtime = RemoteMultiplexerRuntimeAdapter(attachment: reference) { command in + if command.contains("ss -xnpH") { + return .init( + stdout: "M|zellij|42|\(Self.hex(client.started))\nP|\(Self.hex(client.endpoint!))\nDONE\n", + stderr: "", exitCode: 0) + } + if command.contains("action list-panes --json") { + return .init( + stdout: Self.frame( + .zellij, native: #"[{"id":1,"is_plugin":false,"pane_cwd":"/native","pane_command":"bash"}]"#, + focus: "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n1 terminal_1 bash"), + stderr: "", exitCode: 0) + } + return .init(stdout: "", stderr: "", exitCode: 79) + } + let update = try await runtime.inspect(terminal: "/dev/pts/1") + #expect(update.active?.pane == "1") + #expect(update.contexts.first?.directory == "/native") + #expect(update.contexts.first?.directorySource == .nativePane) + #expect(update.contexts.first?.capabilities.isEmpty == true) + } + + @Test @MainActor func detectedMultiplexerNeverRetainsTheOuterDirectoryOnFailure() { + let model = TerminalContextCoordinator() + let shell = TerminalShellContext( + identity: .init(backend: "shell", server: "s", session: "s", pane: "shell", shellInstance: "outer"), + shell: "bash", directory: "/outer", home: nil, promptReady: false, sequence: 1, capabilities: []) + let current = TerminalRuntimeUpdate(status: .ordinary, active: shell.identity, contexts: [shell]) + model.receive(current, generation: model.generation) + let failed = TerminalRuntimeUpdate(status: .unavailable, backend: .zellij, issue: .clientUnbound) + let reconciled = RemoteTerminalContextFilter.reconcileObservation(failed, current: current) + model.receive(reconciled, generation: model.generation) + #expect(model.directory == nil) + #expect(model.backend == .zellij) + #expect(model.issue == .clientUnbound) + #expect(model.ticket(requiring: .directory) == nil) + } + + @Test @MainActor func zellijNativeDirectoryDoesNotGrantShellOrInputPermissions() throws { + let native = """ + [{"id":1,"is_plugin":false,"pane_cwd":"/tmp/a|b","pane_command":"bash"}, + {"id":2,"is_plugin":false,"pane_cwd":"/other","pane_command":"zsh"}] + """ + let update = try RemoteNativeMultiplexerRuntime.parse( + Self.frame(.zellij, native: native, focus: "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n7 terminal_1 bash"), + client: Self.client(.zellij)) + let model = TerminalContextCoordinator() + model.receive(update, generation: model.generation) + #expect(model.directory == "/tmp/a|b") + #expect(model.active?.directorySource == .nativePane) + #expect(model.active?.historySource == nil) + #expect(model.active?.promptReady == false) + for capability in [ + TerminalContextCapabilities.directory, .commandLifecycle, .readDraft, .replaceDraft, + ] { + #expect(model.ticket(requiring: capability) == nil) + } + } + + @Test func zellijOnlyUsesUnanimousFocusWithoutATtyToNativeClientIDMapping() { + let first = "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND\n1 terminal_2 bash" + #expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 terminal_2 zsh") == "2") + #expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 terminal_3 zsh") == nil) + #expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n2 plugin_3 menu") == nil) + #expect(RemoteNativeMultiplexerRuntime.zellijPane(first + "\n1 terminal_2 bash") == nil) + } + + @Test func zmxReadsTheBoundShellDirectoryNotTheCachedListDirectory() throws { + let native = """ + name=other\tpid=222\tclients=1\tcreated=123\tcwd=/other + name=work\tpid=123\tclients=2\tcreated=456\tcwd=/stale + """ + let update = try RemoteNativeMultiplexerRuntime.parse( + Self.frame(.zmx, native: native, focus: "123:process-start", directory: "/actual"), + client: Self.client(.zmx)) + #expect(update.contexts.first?.directory == "/actual") + #expect(update.contexts.first?.directorySource == .process) + #expect(update.contexts.first?.capabilities.isEmpty == true) + let replaced = try RemoteNativeMultiplexerRuntime.parse( + Self.frame(.zmx, native: native, focus: "124:process-start", directory: "/wrong"), + client: Self.client(.zmx)) + #expect(replaced.active == nil) + } + + @Test func herdrDoesNotSubstituteGlobalFocusOrSavedWorkspaceCwd() throws { + let client = Self.client(.herdr) + let cached = try RemoteNativeMultiplexerRuntime.parse( + Self.frame(.herdr, native: #"{"result":{"panes":[{"pane_id":"w1:p1","cwd":"/saved"}]}}"#), + client: client) + #expect(cached.active == nil) + #expect(cached.issue == .directoryUnavailable) + let live = try RemoteNativeMultiplexerRuntime.parse( + Self.frame( + .herdr, + native: #"{"result":{"panes":[{"pane_id":"w1:p1","cwd":"/saved","foreground_cwd":"/actual"}]}}"#), + client: client) + #expect(live.contexts.first?.directory == "/actual") + let ambiguous = try RemoteNativeMultiplexerRuntime.parse( + Self.frame( + .herdr, + native: + #"{"result":{"panes":[{"pane_id":"w1:p1","foreground_cwd":"/a","focused":true},{"pane_id":"w2:p1","foreground_cwd":"/b"}]}}"# + ), + client: client) + #expect(ambiguous.active == nil) + #expect(ambiguous.issue == .paneAmbiguous) + } + + @Test func tmuxUnintegratedPaneGetsOnlyANativeDisplayDirectory() throws { + let frame = """ + tmux 3.3a + C|/dev/pts/1|555|100|777|$0|99|%2||root + P|%2|456|0|0|bash + D|%2|\(Self.hex("/native"))|\(Self.hex("bash")) + DONE + + """ + let update = try RemoteTmuxRuntimeDialect.parse(frame, terminal: "/dev/pts/1") + #expect(update.contexts.first?.directory == "/native") + #expect(update.contexts.first?.directorySource == .nativePane) + #expect(update.contexts.first?.capabilities.isEmpty == true) + let nested = try RemoteTmuxRuntimeDialect.parse( + frame.replacingOccurrences(of: Self.hex("bash") + "\n", with: Self.hex("zellij") + "\n"), + terminal: "/dev/pts/1") + #expect(nested.active == nil) + #expect(nested.issue == .nestedMultiplexer) + } + + @Test func nativeResponsesRejectTruncationAndNonPaths() throws { + let text = Self.frame(.zmx, native: "name=work\tpid=123\tclients=1\tcreated=456", focus: "123:start") + #expect(throws: (any Error).self) { + try RemoteNativeMultiplexerRuntime.parse(String(text.dropLast()), client: Self.client(.zmx)) + } + #expect(try RemoteNativeMultiplexerRuntime.parse(text, client: Self.client(.zmx)).active == nil) + #expect(throws: (any Error).self) { + try RemoteTerminalProcessProbe.parse("M|zmx|42|00\nS|\nDONE\n") + } + } +} diff --git a/FrameworkTests/RemoteMultiplexerTests.swift b/FrameworkTests/RemoteMultiplexerTests.swift index 0cc0d306..4515a906 100644 --- a/FrameworkTests/RemoteMultiplexerTests.swift +++ b/FrameworkTests/RemoteMultiplexerTests.swift @@ -320,8 +320,8 @@ nonisolated struct RemoteMultiplexerTests { #expect(create.contains("TERMIND_CREATED")) switch kind { case .zellij: - #expect(create.contains("--session-serialization false")) - #expect(create.contains("--layout-string 'layout { pane; }'")) + #expect(create.contains("session_serialization false")) + #expect(create.contains("--layout-string \"default_shell \\\"$helper/shell\\\"")) case .zmx: #expect(create.contains("termind_owner=\(Self.token.uuidString)")) #expect(create.contains("? 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. + /// Ending an owned session is best-effort; failures fall back to disconnecting + /// without discarding its recovery identity or interrupting the close with UI. public func close( attachments: [RemoteSessionReference], intent: TerminalTeardownIntent = .explicitClose, terminate: @MainActor (RemoteSessionReference) async throws -> Void @@ -46,39 +29,16 @@ public final class RemoteSessionCloseCoordinator { let attempt = generation isClosing = true defer { isClosing = false } - var remaining = managed - var failure: String? - while !remaining.isEmpty { + for attachment in managed { 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 } + try await terminate(attachment) + } catch is CancellationError { + return false } catch { - failure = error.localizedDescription + // Ownership checks still apply. Never force termination or mark a failed session ended. } } - return true + return generation == attempt && !Task.isCancelled } } diff --git a/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift index 3dc3ccd4..b0b91eec 100644 --- a/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift +++ b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift @@ -25,6 +25,8 @@ public final class TerminalContextCoordinator { public private(set) var active: TerminalShellContext? public private(set) var contexts: [TerminalContextIdentity: TerminalShellContext] = [:] public private(set) var clientIdentity: String? + public private(set) var backend: SessionPersistenceBackendID? + public private(set) var issue: TerminalRuntimeIssue? private var sequences: [TerminalContextIdentity: UInt64] = [:] private var running: Set = [] private var inspectionID: UUID? @@ -43,6 +45,8 @@ public final class TerminalContextCoordinator { status = .unknown active = nil clientIdentity = nil + backend = nil + issue = nil contexts.removeAll() sequences.removeAll() running.removeAll() @@ -53,6 +57,7 @@ public final class TerminalContextCoordinator { revision &+= 1 active = nil status = .unknown + issue = .inspectionUnavailable } public func noteInput() { revision &+= 1 } @@ -133,8 +138,10 @@ public final class TerminalContextCoordinator { return true } if status != update.status || clientIdentity != update.clientIdentity + || backend != update.backend || issue != update.issue || active?.identity != nextActive?.identity || active?.promptReady != nextActive?.promptReady || active?.directory != nextActive?.directory || active?.capabilities != nextActive?.capabilities + || active?.directorySource != nextActive?.directorySource || activeInteractionChanged || nextActive.map({ interrupted.contains($0.identity) }) == true { @@ -142,6 +149,8 @@ public final class TerminalContextCoordinator { } status = update.status clientIdentity = update.clientIdentity + backend = update.backend + issue = update.issue active = nextActive contexts = next if update.status == .multiplexer { diff --git a/Frameworks/TermindDomain/Sessions/TerminalContext.swift b/Frameworks/TermindDomain/Sessions/TerminalContext.swift index 4d8c465c..3c150ccc 100644 --- a/Frameworks/TermindDomain/Sessions/TerminalContext.swift +++ b/Frameworks/TermindDomain/Sessions/TerminalContext.swift @@ -26,11 +26,21 @@ public nonisolated struct TerminalContextCapabilities: OptionSet, Sendable, Equa public static let replaceDraft = Self(rawValue: 1 << 3) } +public nonisolated enum TerminalDirectorySource: Sendable, Equatable { + case shell, nativePane, process +} + +public nonisolated enum TerminalRuntimeIssue: Sendable, Equatable { + case clientUnbound, paneAmbiguous, directoryUnavailable, shellIntegrationUnavailable + case unsupportedVersion, nestedMultiplexer, inspectionUnavailable +} + public nonisolated struct TerminalShellContext: Sendable, Equatable { public let identity: TerminalContextIdentity public var shell: String /// Last cwd reported by this shell instance, independent of foreground/input readiness. public var directory: String? + public var directorySource: TerminalDirectorySource? public var home: String? public var historyPath: String? public var promptReady: Bool @@ -48,11 +58,13 @@ public nonisolated struct TerminalShellContext: Sendable, Equatable { public init( identity: TerminalContextIdentity, shell: String, directory: String?, home: String?, - promptReady: Bool, sequence: UInt64, capabilities: TerminalContextCapabilities, historyPath: String? = nil + promptReady: Bool, sequence: UInt64, capabilities: TerminalContextCapabilities, historyPath: String? = nil, + directorySource: TerminalDirectorySource? = .shell ) { self.identity = identity self.shell = shell self.directory = directory + self.directorySource = directory == nil ? nil : directorySource self.home = home self.historyPath = historyPath self.promptReady = promptReady @@ -89,15 +101,20 @@ public nonisolated struct TerminalRuntimeUpdate: Sendable, Equatable { public var active: TerminalContextIdentity? public var contexts: [TerminalShellContext] public var events: [TerminalContextEvent] + public var backend: SessionPersistenceBackendID? + public var issue: TerminalRuntimeIssue? public init( status: Status, clientIdentity: String? = nil, active: TerminalContextIdentity? = nil, - contexts: [TerminalShellContext] = [], events: [TerminalContextEvent] = [] + contexts: [TerminalShellContext] = [], events: [TerminalContextEvent] = [], + backend: SessionPersistenceBackendID? = nil, issue: TerminalRuntimeIssue? = nil ) { self.status = status self.clientIdentity = clientIdentity self.active = active self.contexts = contexts self.events = events + self.backend = backend + self.issue = issue } } diff --git a/Frameworks/TermindPresentation/RemoteSessionPromptView.swift b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift index a66183bc..5ddfbfd7 100644 --- a/Frameworks/TermindPresentation/RemoteSessionPromptView.swift +++ b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift @@ -116,41 +116,3 @@ public struct RemoteSessionPromptView: View { } } } - -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/SessionPersistence.swift b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift index acde2485..372f223e 100644 --- a/Frameworks/TermindPresentation/Strings/SessionPersistence.swift +++ b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift @@ -19,18 +19,9 @@ extension AppStrings { public static var startupUnavailable: LocalizedStringResource { "The startup command could not run in the session shell. Check the shell and backend configuration." } - 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." } diff --git a/Frameworks/TermindPresentation/Strings/Workspace.swift b/Frameworks/TermindPresentation/Strings/Workspace.swift index c96b86a6..5116ba22 100644 --- a/Frameworks/TermindPresentation/Strings/Workspace.swift +++ b/Frameworks/TermindPresentation/Strings/Workspace.swift @@ -266,22 +266,6 @@ extension AppStrings { "Close All Windows" } - public static var closeThisWindowConfirmation: LocalizedStringResource { - "Close this Window?" - } - - public static var quitTermindConfirmation: LocalizedStringResource { - "Quit Termind?" - } - - public static var closeTermind: LocalizedStringResource { - "Close Termind" - } - - public static var quitTermindWarning: LocalizedStringResource { - "Quitting Termind will close all open Workspaces and disconnect their sessions." - } - public static var connectionLinkFailed: LocalizedStringResource { "Connection Link Failed" } @@ -362,10 +346,6 @@ extension AppStrings { "Saved workspaces could not be restored. The saved data has been kept." } - public static var closeWindowWarning: LocalizedStringResource { - "This window still has open Workspaces. Closing it will close them and disconnect their sessions." - } - public static var unavailableWorkspaceExplanation: LocalizedStringResource { "This workspace is no longer available. It may have ended, its host may have been removed, or an earlier app version may not have saved it." } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift index befad11e..10635e82 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift @@ -128,9 +128,13 @@ nonisolated struct RemoteHerdrDialect: RemoteSocketMultiplexerDialect { func runtimeCommands(name: String) -> (String, String) { ("\"$exe\" session list --json", "\"$exe\" --session \(name) pane list") } - func activePane(native: String, focus: String, reference: RemoteSessionReference) throws -> String? { + func activePane( + native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool = false + ) throws -> String? { let sessions = try RemoteHerdrCommands.parseList(native) - guard sessions.count == 1, sessions.first?.name == reference.session.name else { return nil } + guard clientBound || sessions.count == 1, sessions.contains(where: { $0.name == reference.session.name }) else { + return nil + } struct Response: Decodable { struct Result: Decodable { struct Pane: Decodable { @@ -146,9 +150,9 @@ nonisolated struct RemoteHerdrDialect: RemoteSocketMultiplexerDialect { guard response.result.panes.count == 1, let pane = response.result.panes.first?.paneID, validPane(pane) else { return nil } - // Global pane state cannot bind an attached client's session/focus, including endpoint switching. - // Journals remain useful for lifecycle events, but active-context operations stay disabled. - return nil + // A verified endpoint with exactly one pane has no per-client pane ambiguity. + // Without that endpoint evidence, even a singleton might belong to a switched-away session. + return clientBound ? pane : nil } func writeDraft(name: String, pane: String, key: String) -> String { "\"$exe\" --session \(name) pane send-text \(pane) \(key)" diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerDialect.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerDialect.swift index b8a9341b..5398785a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerDialect.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerDialect.swift @@ -66,7 +66,8 @@ nonisolated protocol RemoteSocketMultiplexerDialect: RemoteMultiplexerDialect { func validateTermination(_ reference: RemoteSessionReference, identity: RemoteMultiplexerIdentity) throws func terminationCleanup(_ reference: RemoteSessionReference, identity: RemoteMultiplexerIdentity) throws -> String func runtimeCommands(name: String) -> (String, String) - func activePane(native: String, focus: String, reference: RemoteSessionReference) throws -> String? + func activePane(native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool) throws + -> String? func writeDraft(name: String, pane: String, key: String) -> String var restoreShellEnvironment: String { get } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift index 7490bfb2..36c81cc7 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift @@ -31,7 +31,60 @@ public nonisolated struct RemoteMultiplexerRuntimeAdapter: TerminalRuntimeAdapte } public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { - try await dialect.runtime(reference: attachment, execute: execute, charset: charset).inspect(terminal: terminal) + // Preserve the native tmux TTY lookup and integrated journal path. Other backends + // can still provide a read-only directory when no Termind helper was installed. + if let attachment { + _ = try dialect + do { + let update = try await dialect.runtime(reference: attachment, execute: execute, charset: charset) + .inspect(terminal: terminal) + if update.active != nil { return update } + } catch is CancellationError { + throw CancellationError() + } catch {} + } else { + do { + let update = try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset) + .inspect(terminal: terminal) + if update.status == .multiplexer || update.backend != nil { return update } + } catch is CancellationError { + throw CancellationError() + } catch {} + } + guard let client = try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) else { + return .init(status: .unknown) + } + do { + if let attachment, client.backend == attachment.backend, + let identity = try? RemoteMultiplexerIdentity.decode( + attachment.session.identity, kind: attachment.backend), + identity.helper != nil, + client.endpoint + == (client.backend == .herdr ? identity.root + "/herdr-client.sock" : identity.socket) + { + do { + let update = try await RemoteSocketMultiplexerRuntime( + reference: attachment, execute: execute, charset: charset, boundClient: client + ) + .inspect(terminal: terminal) + if update.active != nil, + try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) == client + { + return update + } + } catch is CancellationError { + throw CancellationError() + } catch {} + } + return try await RemoteNativeMultiplexerRuntime(client: client, execute: execute).inspect( + terminal: terminal) + } catch is CancellationError { + throw CancellationError() + } catch { + return .init( + status: .multiplexer, clientIdentity: client.identity, backend: client.backend, + issue: .inspectionUnavailable) + } } static func helper(for attachment: RemoteSessionReference?) throws -> String? { @@ -58,6 +111,7 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { let reference: RemoteSessionReference let execute: RemoteSessionCommandExecutor var charset: RemoteCharset = .utf8 + var boundClient: RemoteTerminalProcessProbe.Client? = nil func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { try await snapshot(terminal: terminal).update @@ -93,6 +147,13 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { throw TerminalRuntimeError.unsupported } let kind = try identity.kind.rawValue + if let boundClient { + return """ + helper=\(try RemoteMultiplexerCommands.quote(helper)) + client='\(boundClient.process)' + \(try RemoteTerminalProcessProbe.guardClient(boundClient, terminal: terminal)) + """ + } return """ helper=\(try RemoteMultiplexerCommands.quote(helper)) \(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false)) @@ -169,17 +230,18 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { """ let result = try await execute(command) guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } - return try Self.parseSnapshot(result.stdout, reference: reference, charset: charset) + return try Self.parseSnapshot( + result.stdout, reference: reference, charset: charset, clientBound: boundClient != nil) } static func parse( - _ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8 + _ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false ) throws -> TerminalRuntimeUpdate { - try parseSnapshot(text, reference: reference, charset: charset).update + try parseSnapshot(text, reference: reference, charset: charset, clientBound: clientBound).update } private static func parseSnapshot( - _ text: String, reference: RemoteSessionReference, charset: RemoteCharset + _ text: String, reference: RemoteSessionReference, charset: RemoteCharset, clientBound: Bool = false ) throws -> Snapshot { guard text.utf8.count <= 1024 * 1024, let dialect = RemoteMultiplexerRegistry.socketDialect(reference.backend) @@ -192,7 +254,8 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { else { throw TerminalRuntimeError.invalidResponse } let native = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 512 * 1024) let focus = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 512 * 1024) - let activePane = try dialect.activePane(native: native, focus: focus, reference: reference) + let activePane = try dialect.activePane( + native: native, focus: focus, reference: reference, clientBound: clientBound) var contexts: [TerminalShellContext] = [] var events: [TerminalContextEvent] = [] var active: TerminalContextIdentity? @@ -215,7 +278,9 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { if record.pane == activePane { guard active == nil else { throw TerminalRuntimeError.invalidResponse } active = context.identity - if foreground, record.ready { context.capabilities.formUnion([.readDraft, .replaceDraft]) } + if foreground, record.ready, !clientBound { + context.capabilities.formUnion([.readDraft, .replaceDraft]) + } } contexts.append(context) events.append(contentsOf: record.events(for: context)) @@ -224,7 +289,8 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { return Snapshot( update: .init( status: .multiplexer, clientIdentity: String(lines[0].dropFirst(2)), - active: active, contexts: contexts, events: events), + active: active, contexts: contexts, events: events, backend: kind, + issue: active == nil ? .paneAmbiguous : nil), native: native, focus: focus) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteNativeMultiplexerRuntime.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteNativeMultiplexerRuntime.swift new file mode 100644 index 00000000..57351829 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteNativeMultiplexerRuntime.swift @@ -0,0 +1,215 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// A native directory is display-only. It never manufactures prompt or draft permissions. +nonisolated struct RemoteNativeMultiplexerRuntime { + let client: RemoteTerminalProcessProbe.Client + let execute: RemoteSessionCommandExecutor + + func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { + guard let endpoint = client.endpoint else { return unavailable(.clientUnbound) } + let result = try await execute(command(terminal: terminal, endpoint: endpoint)) + guard result.exitCode == 0, !result.truncated else { return unavailable(.inspectionUnavailable) } + let update = try Self.parse(result.stdout, client: client) + guard try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) == client else { + return unavailable(.clientUnbound) + } + return update + } + + private func unavailable(_ issue: TerminalRuntimeIssue) -> TerminalRuntimeUpdate { + .init(status: .multiplexer, clientIdentity: client.identity, backend: client.backend, issue: issue) + } + + private func command(terminal: String, endpoint: String) throws -> String { + let path = endpoint as NSString + let name = path.lastPathComponent + let root = path.deletingLastPathComponent + let body: String + switch client.backend { + case .zellij: + guard RemoteMultiplexerCommands.validName(name) else { throw TerminalRuntimeError.unsupported } + body = """ + root=\(try RemoteMultiplexerCommands.quote(root)) + release=$(printf '%s' "$version" | awk '{print $2}') + case "${root##*/}" in "$release"|contract_version_*) root=${root%/*} ;; esac + export ZELLIJ_SOCKET_DIR="$root" + focus=$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-clients) || exit 79 + native=$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-panes --json) || exit 79 + [ "$focus" = "$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-clients)" ] || exit 79 + """ + case .zmx: + guard RemoteMultiplexerCommands.validName(name) else { throw TerminalRuntimeError.unsupported } + body = """ + export ZMX_DIR=\(try RemoteMultiplexerCommands.quote(root)) + native=$("$exe" list) || exit 79 + pid=$(printf '%s\\n' "$native" | awk -F '\\t' -v target=\(try RemoteMultiplexerCommands.quote(name)) ' + {sub(/^ +/, "", $1); n=""; p=""; for(i=1;i<=NF;i++) { + if($i ~ /^name=/) n=substr($i,6) + if($i ~ /^pid=[0-9]+$/) p=substr($i,5) + } if(n==target) print p}') + case "$pid" in ''|*[!0-9]*) exit 79 ;; esac + parent=$(ps -p "$pid" -o ppid= | tr -d ' ') + parent_name=$(ps -p "$parent" -o comm=) + [ "${parent_name##*/}" = zmx ] || exit 79 + born=$(process_start "$pid") + shell=$(ps -p "$pid" -o comm=); shell=${shell##*/}; shell=${shell#-} + case "$shell" in bash|zsh|fish) ;; *) printf 'NESTED\\n'; exit 0 ;; esac + tty=$(ps -p "$pid" -o tty= | tr -d ' ') + foreground=$(ps -t "$tty" -o pgid= -o tpgid= -o comm= | awk '$1==$2 && $2>0 {n=$3; sub(/^.*\\//,"",n); print n}') + case "$foreground" in *tmux*|*zellij*|*zmx*|*herdr*|ssh|mosh|et) printf 'NESTED\\n'; exit 0 ;; esac + if [ -d "/proc/$pid" ]; then directory=$(readlink "/proc/$pid/cwd") + elif command -v lsof >/dev/null 2>&1; then + directory=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p') + fi + [ -n "$born" ] && [ "$(process_start "$pid")" = "$born" ] || exit 79 + focus="$pid:$born" + [ "$native" = "$("$exe" list)" ] || exit 79 + """ + case .herdr: + guard name == "herdr-client.sock" else { throw TerminalRuntimeError.unsupported } + body = """ + export HERDR_SOCKET_PATH=\(try RemoteMultiplexerCommands.quote(root + "/herdr.sock")) + api_fp=$(fingerprint "$HERDR_SOCKET_PATH") || exit 79 + native=$("$exe" pane list) || exit 79 + [ "$(fingerprint "$HERDR_SOCKET_PATH")" = "$api_fp" ] || exit 79 + """ + default: throw TerminalRuntimeError.unsupported + } + let versionArgument = client.backend == .zmx ? "version" : "--version" + return """ + \(RemoteMultiplexerCommands.discovery(client.backend)) + \(RemoteMultiplexerCommands.utilities) + \(try RemoteTerminalProcessProbe.guardClient(client, terminal: terminal)) + endpoint=\(try RemoteMultiplexerCommands.quote(endpoint)) + fp=$(fingerprint "$endpoint") || exit 79 + version=$("$exe" \(versionArgument)) || exit 79 + native=; focus=; directory= + \(body) + [ "$(fingerprint "$endpoint")" = "$fp" ] || exit 79 + \(try RemoteTerminalProcessProbe.guardClient(client, terminal: terminal)) + printf 'V|%s\\nI|%s\\nN|%s\\nF|%s\\nD|%s\\nDONE\\n' \ + "$(hex "$version")" "$fp" "$(hex "$native")" "$(hex "$focus")" "$(hex "$directory")" + """ + } + + static func parse(_ text: String, client: RemoteTerminalProcessProbe.Client) throws -> TerminalRuntimeUpdate { + func unavailable(_ issue: TerminalRuntimeIssue) -> TerminalRuntimeUpdate { + .init(status: .multiplexer, clientIdentity: client.identity, backend: client.backend, issue: issue) + } + if text == "NESTED\n" { return unavailable(.nestedMultiplexer) } + let lines = text.components(separatedBy: "\n") + guard lines.count == 7, lines[5] == "DONE", lines[6].isEmpty, + zip(lines, ["V|", "I|", "N|", "F|", "D|"]).allSatisfy({ $0.hasPrefix($1) }), + let endpoint = client.endpoint + else { throw TerminalRuntimeError.invalidResponse } + let version = try RemoteMultiplexerCommands.unhex(String(lines[0].dropFirst(2))) + do { + try RemoteMultiplexerRegistry.dialect(client.backend)?.validateVersion(version) + } catch { + return unavailable(.unsupportedVersion) + } + let fingerprint = String(lines[1].dropFirst(2)) + guard fingerprint.split(separator: ":").count == 3, + fingerprint.utf8.allSatisfy({ (48...57).contains($0) || $0 == 58 }) + else { throw TerminalRuntimeError.invalidResponse } + let native = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 1024 * 1024) + let focus = try RemoteMultiplexerCommands.unhex(String(lines[3].dropFirst(2)), limit: 262144) + var directory = try RemoteMultiplexerCommands.unhex(String(lines[4].dropFirst(2))) + let pane: String + var source = TerminalDirectorySource.nativePane + switch client.backend { + case .zellij: + guard let selected = zellijPane(focus) else { return unavailable(.paneAmbiguous) } + struct Pane: Decodable { + let id: Int + let isPlugin: Bool + let paneCwd: String? + let paneCommand: String? + let exited: Bool? + + enum CodingKeys: String, CodingKey { + case id, exited + case isPlugin = "is_plugin" + case paneCwd = "pane_cwd" + case paneCommand = "pane_command" + } + } + let panes = try JSONDecoder().decode([Pane].self, from: Data(native.utf8)) + let matches = panes.filter { !$0.isPlugin && String($0.id) == selected && $0.exited != true } + guard matches.count == 1, let selectedPane = matches.first else { return unavailable(.paneAmbiguous) } + if isNested(selectedPane.paneCommand) { return unavailable(.nestedMultiplexer) } + pane = selected + directory = selectedPane.paneCwd ?? "" + case .zmx: + let path = endpoint as NSString + let root = path.deletingLastPathComponent.utf8.map { String(format: "%02x", $0) }.joined() + let sessions = try RemoteZmxCommands.parseList("ROOT|\(root)\n" + native + "\nEND\n") + guard let session = sessions.first(where: { $0.name == path.lastPathComponent }), + let process = session.process, focus.hasPrefix(process + ":"), session.clients.map({ $0 > 0 }) == true + else { return unavailable(.clientUnbound) } + pane = "shell" + source = .process + case .herdr: + struct Response: Decodable { + struct Result: Decodable { + struct Pane: Decodable { + let paneID: String + let foregroundCwd: String? + + enum CodingKeys: String, CodingKey { + case paneID = "pane_id" + case foregroundCwd = "foreground_cwd" + } + } + let panes: [Pane] + } + let result: Result + } + let response = try JSONDecoder().decode(Response.self, from: Data(native.utf8)) + // Herdr clients can have independent tab locations. Global focus is not client focus. + guard response.result.panes.count == 1, let selected = response.result.panes.first, + RemoteHerdrDialect().validPane(selected.paneID) + else { return unavailable(.paneAmbiguous) } + pane = selected.paneID + directory = selected.foregroundCwd ?? "" + source = .process + default: throw TerminalRuntimeError.unsupported + } + guard RemoteMultiplexerCommands.validPath(directory) else { return unavailable(.directoryUnavailable) } + let identity = TerminalContextIdentity( + backend: client.backend.rawValue, server: endpoint + ":" + fingerprint, session: endpoint, + pane: pane, shellInstance: "native:" + (focus.isEmpty ? pane : focus)) + let context = TerminalShellContext( + identity: identity, shell: "", directory: directory, home: nil, promptReady: false, sequence: 0, + capabilities: [], directorySource: source) + return .init( + status: .multiplexer, clientIdentity: client.identity, active: identity, contexts: [context], + backend: client.backend, issue: .shellIntegrationUnavailable) + } + + static func zellijPane(_ text: String) -> String? { + let lines = text.split(separator: "\n").map { $0.split(whereSeparator: \.isWhitespace).map(String.init) } + guard lines.first == ["CLIENT_ID", "ZELLIJ_PANE_ID", "RUNNING_COMMAND"], lines.count > 1 else { return nil } + var panes: Set = [] + var clients: Set = [] + for fields in lines.dropFirst() { + guard fields.count >= 2, UInt64(fields[0]) != nil, clients.insert(fields[0]).inserted, + fields[1].hasPrefix("terminal_") + else { return nil } + let pane = String(fields[1].dropFirst("terminal_".count)) + guard RemoteZellijDialect().validPane(pane) else { return nil } + panes.insert(pane) + } + // Without a native TTY-to-client ID API, only a unanimous focus is safe for display. + return panes.count == 1 ? panes.first : nil + } + + static func isNested(_ command: String?) -> Bool { + guard let command else { return false } + let executable = (command.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? "") as NSString + return SessionPersistenceBackendID.supported.contains(.init(rawValue: executable.lastPathComponent)) + || ["ssh", "mosh", "et"].contains(executable.lastPathComponent) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift index b8b0462a..6944d66f 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift @@ -90,7 +90,8 @@ nonisolated struct RemoteTerminalContextFilter { ) -> TerminalRuntimeUpdate { // A negative multiplexer probe does not invalidate the authenticated outer // shell's cwd. Keep polling for manual attach, without restoring prompt rights. - if observation.status == .unknown || observation.status == .unavailable, + if observation.backend == nil, + observation.status == .unknown || observation.status == .unavailable, let current, current.status == .ordinary, let shell = current.contexts.first(where: { $0.identity == current.active }), shell.identity.backend == "shell", !shell.promptReady diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift new file mode 100644 index 00000000..b2f2b80a --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift @@ -0,0 +1,234 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// Read-only OS evidence. An argv session name is never proof of a client's current endpoint. +nonisolated struct RemoteTerminalProcessProbe { + struct Client: Equatable { + let backend: SessionPersistenceBackendID + let process: String + let started: String + let endpoint: String? + + var identity: String { process + ":" + started } + } + + let execute: RemoteSessionCommandExecutor + + func inspect(terminal: String) async throws -> Client? { + 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) + } + + static func command(terminal: String) throws -> String { + guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse } + let names = SessionPersistenceBackendID.supported.map(\.rawValue).joined(separator: "|") + return """ + export LC_ALL=C + \(RemoteMultiplexerCommands.utilities) + tty=\(try RemoteMultiplexerCommands.quote(String(terminal.dropFirst("/dev/".count)))) + rows=$(ps -t "$tty" -o pid= -o pgid= -o tpgid= -o comm=) || exit 79 + clients=$(printf '%s\\n' "$rows" | awk ' + $2 == $3 && $3 > 0 { + name=$4; sub(/^.*\\//,"",name); sub(/:$/,"",name) + if (name ~ /^(\(names))$/) print name "|" $1 + }') + if [ -z "$clients" ]; then printf 'NONE\\n'; exit 0; fi + [ "$(printf '%s\\n' "$clients" | wc -l | tr -d ' ')" = 1 ] || { printf 'AMBIGUOUS\\n'; exit 0; } + kind=${clients%|*}; client=${clients#*|} + started=$(process_start "$client") + [ -n "$started" ] || exit 79 + printf 'M|%s|%s|%s\\n' "$kind" "$client" "$(hex "$started")" + if command -v ss >/dev/null 2>&1; then + sockets=$(ss -xnpH 2>/dev/null | head -c 262145) + printf 'S|%s\\n' "$(hex "$sockets")" + elif [ "$(uname -s)" = Linux ] && command -v python3 >/dev/null 2>&1; then + endpoint=$(python3 - "$client" 2>/dev/null <<'TERMIND_SOCKET_DIAG' + \(linuxSocketDiagnostic) + TERMIND_SOCKET_DIAG + ) || endpoint= + printf 'P|%s\\n' "$endpoint" + elif [ "$(uname -s)" = Darwin ] && command -v lsof >/dev/null 2>&1; then + pids=$(ps -u "$(id -u)" -o pid= -o comm= | awk -v kind="$kind" ' + {name=$2; sub(/^.*\\//,"",name); if(name==kind) {if(p!="") p=p ","; p=p $1}} END {print p}') + sockets=$(lsof -nP -a -p "$pids" -U -F pdfn 2>/dev/null | head -c 262145) + printf 'L|%s\\n' "$(hex "$sockets")" + else + printf 'S|\\n' + fi + [ "$(process_start "$client")" = "$started" ] || exit 79 + printf 'DONE\\n' + """ + } + + static func parse(_ text: String) throws -> Client? { + if text == "NONE\n" { return nil } + guard text != "AMBIGUOUS\n" else { throw TerminalRuntimeError.invalidResponse } + let lines = text.components(separatedBy: "\n") + guard lines.count == 4, lines[2] == "DONE", lines[3].isEmpty else { + throw TerminalRuntimeError.invalidResponse + } + let meta = lines[0].components(separatedBy: "|") + guard meta.count == 4, meta[0] == "M", + SessionPersistenceBackendID.supported.contains(.init(rawValue: meta[1])), + UInt64(meta[2]).map({ $0 > 1 }) == true, + lines[1].hasPrefix("S|") || lines[1].hasPrefix("L|") || lines[1].hasPrefix("P|") + else { throw TerminalRuntimeError.invalidResponse } + let started = try RemoteMultiplexerCommands.unhex(meta[3]) + guard !started.isEmpty, started.utf8.allSatisfy({ (32...126).contains($0) }) else { + throw TerminalRuntimeError.invalidResponse + } + let evidence = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 262144) + let endpoint: String? + if lines[1].hasPrefix("P|") { + endpoint = RemoteMultiplexerCommands.validPath(evidence) ? evidence : nil + } else { + endpoint = + lines[1].hasPrefix("S|") + ? linuxEndpoint(evidence, process: meta[2]) : darwinEndpoint(evidence, process: meta[2]) + } + return Client(backend: .init(rawValue: meta[1]), process: meta[2], started: started, endpoint: endpoint) + } + + /// Linux exposes UNIX peers through SOCK_DIAG, not /proc/net/unix. Use the + /// standard library when iproute2 is absent, without installing remote helpers. + static let linuxSocketDiagnostic = #""" + import os, socket, struct, sys + + owned = set() + for entry in os.scandir("/proc/" + sys.argv[1] + "/fd"): + try: + link = os.readlink(entry.path) + if link.startswith("socket:[") and link.endswith("]"): + owned.add(int(link[8:-1])) + except (OSError, ValueError): + pass + if not owned: + sys.exit(0) + request = struct.pack("=BBHIIIII", socket.AF_UNIX, 0, 0, 0xffffffff, 0, 5, 0xffffffff, 0xffffffff) + header = struct.pack("=IHHII", 16 + len(request), 20, 0x301, 1, 0) + peers = {} + with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, 4) as channel: + channel.settimeout(0.5) + channel.bind((0, 0)) + channel.sendto(header + request, (0, 0)) + done = False + total = 0 + while not done: + data, _, flags, sender = channel.recvmsg(65536) + total += len(data) + if flags & socket.MSG_TRUNC or total > 1048576 or sender[0] != 0: + sys.exit(1) + offset = 0 + while offset < len(data): + length, kind, flags, seq, _ = struct.unpack_from("=IHHII", data, offset) + if length < 16 or offset + length > len(data) or seq != 1 or flags & 0x10: + sys.exit(1) + end = offset + length + if kind == 3: + done = True + elif kind == 2: + sys.exit(1) + elif kind == 20: + family, _, _, _, inode, _, _ = struct.unpack_from("=BBBBIII", data, offset + 16) + if family != socket.AF_UNIX: + sys.exit(1) + peer, path = None, b"" + attribute = offset + 32 + while attribute < end: + size, tag = struct.unpack_from("=HH", data, attribute) + if size < 4 or attribute + size > end: + sys.exit(1) + value = data[attribute + 4:attribute + size] + if tag == 0: + path = value.rstrip(b"\0") + elif tag == 2: + peer, = struct.unpack("=I", value) + attribute += (size + 3) & ~3 + peers[inode] = (peer, path) + offset += (length + 3) & ~3 + paths = set() + for inode in owned: + peer, _ = peers.get(inode, (None, b"")) + reciprocal, path = peers.get(peer, (None, b"")) + if reciprocal == inode and path.startswith(b"/"): + paths.add(path) + if len(paths) == 1: + print(paths.pop().hex()) + """# + + static func linuxEndpoint(_ text: String, process: String) -> String? { + struct Socket { + let local: String + let peer: String + let path: String + let owner: Bool + } + let sockets: [Socket] = text.split(separator: "\n").compactMap { line in + let fields = line.split(whereSeparator: \.isWhitespace).map(String.init) + guard fields.count >= 8, fields[0] == "u_str", fields[1] == "ESTAB", + UInt64(fields[5]) != nil, UInt64(fields[7]) != nil + else { return nil } + return Socket( + local: fields[5], peer: fields[7], path: fields[4], + owner: fields.dropFirst(8).joined().contains("pid=\(process),")) + } + var paths: Set = [] + for client in sockets where client.owner { + for peer in sockets where peer.local == client.peer && peer.peer == client.local { + if RemoteMultiplexerCommands.validPath(peer.path) { paths.insert(peer.path) } + } + } + return paths.count == 1 ? paths.first : nil + } + + static func darwinEndpoint(_ text: String, process: String) -> String? { + struct Socket { + let process: String + let device: String + let name: String + } + var sockets: [Socket] = [] + var pid = "" + var device = "" + var name = "" + func append() { + if !device.isEmpty, !name.isEmpty { sockets.append(.init(process: pid, device: device, name: name)) } + device = "" + name = "" + } + for line in text.split(separator: "\n") { + switch line.first { + case "p": + append() + pid = String(line.dropFirst()) + case "f": append() + case "d": device = String(line.dropFirst()) + case "n": name = String(line.dropFirst()) + default: break + } + } + append() + var paths: Set = [] + for client in sockets where client.process == process { + guard let arrow = client.name.range(of: "->") else { continue } + let peerDevice = String(client.name[arrow.upperBound...]) + for peer in sockets where peer.device == peerDevice { + let path = peer.name.components(separatedBy: "->")[0] + if RemoteMultiplexerCommands.validPath(path) { paths.insert(path) } + } + } + return paths.count == 1 ? paths.first : nil + } + + static func guardClient(_ client: Client, terminal: String) throws -> String { + """ + [ "$(process_start '\(client.process)')" = \(try RemoteMultiplexerCommands.quote(client.started)) ] || exit 79 + [ "/dev/$(ps -p '\(client.process)' -o tty= | tr -d ' ')" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79 + groups=$(ps -p '\(client.process)' -o pgid= -o tpgid=) || exit 79 + set -- $groups + [ "$#" = 2 ] && [ "$1" = "$2" ] && [ "$1" -gt 0 ] || exit 79 + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift index 1b4c148e..617bcfb7 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift @@ -73,6 +73,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { 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 + pane=$(printf '%s' "$client" | cut -d '|' -f8) + cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78 + foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78 + printf 'D|%s|%s|%s\\n' "$pane" \ + "$(printf '%s' "$cwd" | od -An -v -tx1 | tr -d ' \\n')" \ + "$(printf '%s' "$foreground" | od -An -v -tx1 | tr -d ' \\n')" 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 @@ -105,13 +111,16 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { 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) + return .init(status: .unavailable, backend: .tmux, issue: .paneAmbiguous) } 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 nativeDirectory: String? + var nativeCommand: String? + var hasNativeDirectory = false var index = 1 while index < lines.count { let line = lines[index] @@ -122,7 +131,16 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { } break } - if line == "BEGIN" { + if line.hasPrefix("D|") { + let fields = line.components(separatedBy: "|") + guard !hasNativeDirectory, fields.count == 4, fields[1] == client[7] else { + throw TerminalRuntimeError.invalidResponse + } + hasNativeDirectory = true + let directory = try RemoteMultiplexerCommands.unhex(fields[2]) + nativeDirectory = RemoteMultiplexerCommands.validPath(directory) ? directory : nil + nativeCommand = try RemoteMultiplexerCommands.unhex(fields[3]) + } else if line == "BEGIN" { let start = index while index < lines.count, lines[index] != "END" { index += 1 } guard index < lines.count else { throw TerminalRuntimeError.invalidResponse } @@ -145,6 +163,7 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { var contexts: [TerminalShellContext] = [] var events: [TerminalContextEvent] = [] var active: TerminalContextIdentity? + let nested = RemoteNativeMultiplexerRuntime.isNested(nativeCommand) for record in records { guard let pane = panes[record.pane], pane.process == record.process else { continue } var context = record.context(server: server, session: session, backend: "tmux") @@ -158,23 +177,27 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { } contexts.append(context) events.append(contentsOf: record.events(for: context)) - if record.pane == client[7] { + if record.pane == client[7], !nested { guard active == nil else { throw TerminalRuntimeError.invalidResponse } active = context.identity } } - if active == nil { + if active == nil, !nested { let identity = TerminalContextIdentity( backend: "tmux", server: server, session: session, pane: client[7], shellInstance: "unintegrated:" + panes[client[7]]!.process) contexts.append( .init( - identity: identity, shell: "", directory: nil, home: nil, promptReady: false, - sequence: 0, capabilities: [])) + identity: identity, shell: "", directory: nativeDirectory, home: nil, promptReady: false, + sequence: 0, capabilities: [], directorySource: .nativePane)) active = identity } return .init( - status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events) + status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events, + backend: .tmux, + issue: nested + ? .nestedMultiplexer + : (active?.shellInstance.hasPrefix("unintegrated:") == true ? .shellIntegrationUnavailable : nil)) } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift index 4f2d985e..04fe8d47 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift @@ -77,8 +77,11 @@ nonisolated enum RemoteZellijCommands { existing=$(find "$root" -type s -name '\(name)' -print) || exit 78 [ -z "$existing" ] || exit 79 native_started=1 - "$exe" --layout-string 'layout { pane; }' attach --create-background '\(name)' options --default-shell "$helper/shell" \ - --session-serialization false >/dev/null || exit 78 + # Background creation ignores attach's CLI options on some Zellij releases. + # Layout configuration reaches the server and also covers panes created later. + "$exe" --layout-string "default_shell \\"$helper/shell\\" + session_serialization false + layout { pane; }" attach --create-background '\(name)' >/dev/null || exit 78 socket=$(find "$root" -type s -name '\(name)' -print) || exit 78 """ } @@ -135,9 +138,15 @@ nonisolated struct RemoteZellijDialect: RemoteSocketMultiplexerDialect { func runtimeCommands(name: String) -> (String, String) { ("\"$exe\" list-sessions --short --no-formatting", "\"$exe\" --session \(name) action list-clients") } - func activePane(native: String, focus: String, reference: RemoteSessionReference) throws -> String? { + func activePane( + native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool = false + ) throws -> String? { // A switched client cannot be bound using its original argv. Require a unique live session too. - guard native == reference.session.name else { return nil } + guard + clientBound + ? native.split(separator: "\n").contains(Substring(reference.session.name)) + : native == reference.session.name + else { return nil } let clients = focus.split(separator: "\n") guard clients.first == "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND", clients.count == 2 else { return nil } let fields = clients[1].split(whereSeparator: \.isWhitespace) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift index 5d7c2d4a..16d9709d 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift @@ -30,7 +30,9 @@ nonisolated enum RemoteZmxCommands { guard RemoteMultiplexerCommands.validPath(root) else { throw RemoteSessionFailure.invalidResponse } return try lines.map { line in var fields: [String: String] = [:] - for part in line.split(separator: "\t") { + for (index, column) in line.split(separator: "\t").enumerated() { + // Recent releases indent the name column even outside a zmx session. + let part = index == 0 ? column.drop(while: { $0 == " " }) : column guard let separator = part.firstIndex(of: "=") else { throw RemoteSessionFailure.invalidResponse } let key = String(part[.. String { """ "$exe" list | awk -F '\\t' -v target='\(name)' ' - { name=""; pid=""; created=""; count=0 + { sub(/^ +/, "", $1); name=""; pid=""; created=""; count=0 for(i=1;i<=NF;i++) { if ($i ~ /^name=/) {name=substr($i,6); count++} if ($i ~ /^pid=[0-9]+$/) {pid=substr($i,5); count++} @@ -143,11 +145,14 @@ nonisolated struct RemoteZmxDialect: RemoteSocketMultiplexerDialect { """ } func runtimeCommands(name: String) -> (String, String) { ("\"$exe\" list", "printf 'shell'") } - func activePane(native: String, focus: String, reference: RemoteSessionReference) throws -> String? { + func activePane( + native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool = false + ) throws -> String? { let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: id) let root = identity.root.utf8.map { String(format: "%02x", $0) }.joined() let sessions = try RemoteZmxCommands.parseList("ROOT|\(root)\n" + native + "\nEND\n") - guard sessions.count == 1, let session = sessions.first, + guard clientBound || sessions.count == 1, + let session = sessions.first(where: { $0.name == reference.session.name }), session.name == reference.session.name, session.process == identity.process, session.created == identity.created, session.clients == 1 else { return nil } diff --git a/Scripts/ssh-tmux-session-fixture.py b/Scripts/ssh-tmux-session-fixture.py index 00b098a5..51271b08 100644 --- a/Scripts/ssh-tmux-session-fixture.py +++ b/Scripts/ssh-tmux-session-fixture.py @@ -19,6 +19,15 @@ For TerminalActivationWireTests, use --max-sessions 8 and run both --debian-release bookworm (tmux 3.3a) and trixie (tmux 3.5a). TerminalActivationWindowTests additionally requires TEST_RUNNER_TERMIND_WINDOW_TESTS=1; it opens native windows and records viewport, Ghostty, PTY and tmux sizes. + +For all four backends, supply --multiplexer-binaries /path/to/linux-binaries +containing zellij, zmx and herdr for the Lima VM's architecture. The fixture +installs them only inside its disposable container and prints their versions. +Use --max-sessions 8 and TEST_RUNNER_TERMIND_MULTIPLEXER_FIXTURE with +-only-testing:TFMTests/NativeMultiplexerRuntimeTests and +-only-testing:TFMTests/RemoteMultiplexerSmokeTests. The runtime suite exercises +manual and managed attachment, unrelated clients, pane changes and reconnect. +No iproute2 is installed, so this also tests endpoint discovery without ss. """ import argparse @@ -39,6 +48,9 @@ def main(): parser.add_argument("--lima", default="default") parser.add_argument("--port", type=int, default=0) parser.add_argument("--max-sessions", type=int, default=1) + parser.add_argument( + "--multiplexer-binaries", type=Path, + help="Directory of Linux zellij, zmx and herdr binaries to install in the disposable container.") parser.add_argument( "--debian-release", choices=["bookworm", "trixie"], default="trixie", help="bookworm supplies tmux 3.3a; trixie supplies tmux 3.5a") @@ -50,6 +62,12 @@ def main(): 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.") + binaries = ["zellij", "zmx", "herdr"] + if args.multiplexer_binaries is not None: + args.multiplexer_binaries = args.multiplexer_binaries.resolve() + for binary in binaries: + if not (args.multiplexer_binaries / binary).is_file(): + parser.error(f"Missing multiplexer binary: {args.multiplexer_binaries / binary}") signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) with socket.socket() as reservation: reservation.bind(("127.0.0.1", args.port)) @@ -78,6 +96,21 @@ def main(): 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") + if args.multiplexer_binaries is not None: + with subprocess.Popen( + ["tar", "-C", str(args.multiplexer_binaries), "-cf", "-", *binaries], + stdout=subprocess.PIPE, + ) as archive: + nerdctl("exec", "-i", name, "tar", "-C", "/usr/local/bin", "-xf", "-", stdin=archive.stdout) + if archive.wait() != 0: + raise RuntimeError("Unable to package multiplexer binaries.") + execute( + "chmod 755 /usr/local/bin/zellij /usr/local/bin/zmx /usr/local/bin/herdr; " + "mkdir -p /root/.config/zellij /root/.config/herdr; " + "printf 'show_startup_tips false\\nshow_release_notes false\\n' " + "> /root/.config/zellij/config.kdl; " + "printf 'onboarding = false\\n' > /root/.config/herdr/config.toml; " + "tmux -V && zellij --version && zmx version && herdr --version") execute( "umask 077; mkdir -p /root/.ssh /run/sshd; cat > /root/.ssh/authorized_keys", input=key.with_suffix(".pub").read_text()) diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 3050b9bb..b8e91a30 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -26,7 +26,7 @@ struct MobileRootView: View { } var body: some View { - content.overlay { RemoteSessionClosePromptView(coordinator: app.persistentClose) } + content } private var content: some View { diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index dbbeaecf..7ef90754 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -231,7 +231,7 @@ final class MobileWorkspace: Identifiable { switch descriptor.kind { case .ssh, .mosh, .eternalTerminal: guard isShellIntegrationEnabled, - shellIntegrationAvailable, + shellIntegrationAvailable || (hasRuntimeContext && terminalContext.active?.directory != nil), let workingDirectory, !workingDirectory.isEmpty else { return nil } @@ -609,7 +609,13 @@ final class MobileWorkspace: Identifiable { { guard sameDirectoryLaunch == expected else { return nil } do { - try await confirmDirectoryContext(terminalContext.ticket(requiring: .directory)) + if hasRuntimeContext, let context = terminalContext.active, let ssh { + let generation = terminalContext.generation + try await ssh.confirmContext(context) + guard terminalContext.generation == generation, terminalContext.active == context else { return nil } + } else { + try await confirmDirectoryContext(terminalContext.ticket(requiring: .directory)) + } return sameDirectoryLaunch == expected ? expected : nil } catch { return nil diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 4d2fa010..39d53f58 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -26,7 +26,7 @@ struct RootView: View { } var body: some View { - content.overlay { RemoteSessionClosePromptView(coordinator: window.persistentClose) } + content } @ViewBuilder private var content: some View { diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 93bafbd5..974fac96 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -475,11 +475,6 @@ struct TermindApp: App { NSApp.reply(toApplicationShouldTerminate: false) return } - if app.hasOpenWorkspaces, !WorkspaceCloseConfirmation.application.confirm() { - self?.awaitingTermination = false - NSApp.reply(toApplicationShouldTerminate: false) - return - } await app.sftpEdits.recheck() if app.sftpEdits.model.hasPendingChanges { let alert = NSAlert() diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index de42ec7d..9e5c0f4b 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -356,7 +356,14 @@ struct StatusBarView: View { private func openInSFTP(_ path: String, placement: SessionOpenPlacement) { guard let host else { return } - window.openSFTPChannel(host: host, initialPath: path, placement: placement) + if let sshSession { + Task { + guard await sshSession.confirmDisplayedDirectory(path) else { return } + window.openSFTPChannel(host: host, initialPath: path, placement: placement) + } + } else { + window.openSFTPChannel(host: host, initialPath: path, placement: placement) + } } private func openInSSH(_ path: String, placement: SessionOpenPlacement) { diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index fb92116d..1ec19ee9 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -380,6 +380,20 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { terminalContext.ticket(requiring: .directory) } + func confirmDisplayedDirectory(_ path: String) async -> Bool { + guard status.isLive, workingDirectory == path else { return false } + guard hasRuntimeContext else { return true } + guard let context = terminalContext.active, let ssh else { return false } + let generation = terminalContext.generation + do { + try await ssh.confirmContext(context) + return terminalContext.generation == generation && terminalContext.active == context + && workingDirectory == path + } catch { + return false + } + } + func isDirectoryContextCurrent(_ ticket: TerminalContextCoordinator.Ticket?) -> Bool { guard hasRuntimeContext else { return ticket == nil } guard let ticket else { return false } diff --git a/TFM/Platform/WorkspaceCloseConfirmation.swift b/TFM/Platform/WorkspaceCloseConfirmation.swift index 62eece80..7e9bbd0f 100644 --- a/TFM/Platform/WorkspaceCloseConfirmation.swift +++ b/TFM/Platform/WorkspaceCloseConfirmation.swift @@ -2,32 +2,6 @@ import AppKit import TermindPresentation enum WorkspaceCloseConfirmation { - case window - case application - - func makeAlert() -> NSAlert { - let alert = NSAlert() - switch self { - case .window: - alert.messageText = String(localized: AppStrings.Workspace.closeThisWindowConfirmation) - alert.informativeText = String(localized: AppStrings.Workspace.closeWindowWarning) - case .application: - alert.messageText = String(localized: AppStrings.Workspace.quitTermindConfirmation) - alert.informativeText = String(localized: AppStrings.Workspace.quitTermindWarning) - } - let close = alert.addButton( - withTitle: String( - localized: self == .window ? AppStrings.Workspace.closeWindow : AppStrings.Workspace.closeTermind)) - let cancel = alert.addButton(withTitle: String(localized: AppStrings.Common.cancel)) - close.keyEquivalent = "\r" - cancel.keyEquivalent = "\u{1b}" - return alert - } - - func confirm(present: (NSAlert) -> NSApplication.ModalResponse = { $0.runModal() }) -> Bool { - present(makeAlert()) == .alertFirstButtonReturn - } - static func confirmDiscardTerminalTheme() -> Bool { let alert = NSAlert() alert.messageText = String(localized: AppStrings.Common.discardChangesQuestion) @@ -37,31 +11,20 @@ enum WorkspaceCloseConfirmation { } } -/// Window dismissal and application termination have different scopes. Keep the -/// window veto out of SwiftUI's dismissal dialog, which also participates in Quit. +/// Preserve native delegate vetoes and unsaved edits without prompting for live connections. nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate { weak var forwardedDelegate: (any NSWindowDelegate)? @MainActor private weak var window: NSWindow? @MainActor private let model: WindowModel - @MainActor private let confirmClose: () -> Bool @MainActor private let confirmDiscardTheme: () -> Bool @MainActor private var isConfirming = false - @MainActor - convenience init( - model: WindowModel, confirmClose: @escaping () -> Bool = { WorkspaceCloseConfirmation.window.confirm() } - ) { - self.init( - model: model, confirmClose: confirmClose, - confirmDiscardTheme: { WorkspaceCloseConfirmation.confirmDiscardTerminalTheme() }) - } - @MainActor init( - model: WindowModel, confirmClose: @escaping () -> Bool, confirmDiscardTheme: @escaping () -> Bool + model: WindowModel, + confirmDiscardTheme: @escaping () -> Bool = { WorkspaceCloseConfirmation.confirmDiscardTerminalTheme() } ) { self.model = model - self.confirmClose = confirmClose self.confirmDiscardTheme = confirmDiscardTheme super.init() } @@ -94,7 +57,6 @@ nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate defer { isConfirming = false } model.saveTerminalThemeChanges() if model.hasUnsavedTerminalThemeChanges, !confirmDiscardTheme() { return false } - if !model.isClosed, !model.workspaces.isEmpty, !confirmClose() { return false } model.clearTerminalThemeEditor() return true } diff --git a/TFMTests/NativeMultiplexerRuntimeTests.swift b/TFMTests/NativeMultiplexerRuntimeTests.swift new file mode 100644 index 00000000..8db066b2 --- /dev/null +++ b/TFMTests/NativeMultiplexerRuntimeTests.swift @@ -0,0 +1,384 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +/// Uses real binaries and SSH PTYs in the disposable fixture, never a user's sessions. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MULTIPLEXER_FIXTURE"] != nil), .serialized) +@MainActor +struct NativeMultiplexerRuntimeTests { + private struct Fixture: Decodable { + let port: Int + let hostFingerprint: String + let privateKeyPath: String + + func request() throws -> SSHConnectionRequest { + let key = try String(contentsOfFile: privateKeyPath, encoding: .utf8) + return SSHConnectionRequest( + configuration: .init( + hostID: UUID(), host: "127.0.0.1", port: port, username: "root", + credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: hostFingerprint)])) + } + } + + private final class State { + let context = TerminalContextCoordinator() + var output = Data() + + func channel() -> SSHPTYChannel { + SSHPTYChannel( + output: { data in Task { @MainActor in self.output.append(data) } }, exit: { _ in }, + shellIntegration: { event in + Task { @MainActor in + if case .runtime(let update) = event { + self.context.receive(update, generation: self.context.generation) + } + } + }) + } + } + + @Test(.timeLimit(.minutes(3)), arguments: SessionPersistenceBackendID.supported, [false, true]) + func manualAndManagedDirectoriesSurviveOtherSessionsPaneChangesAndReconnect( + backendID: SessionPersistenceBackendID, managed: Bool + ) async throws { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_MULTIPLEXER_FIXTURE"]) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + let hostID = UUID() + let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await control.configureStartup { _ in nil } + try await connect(control, fixture: fixture) + try await control.releaseTerminalForControl() + let execute = try await control.sessionCommandExecutor() + let backend = RemoteMultiplexerService(backend: backendID, execute: execute) + let root = "/tmp/termind-wire-" + UUID().uuidString.prefix(8).lowercased() + let names = [ + "native-" + UUID().uuidString.prefix(8).lowercased(), "other-" + UUID().uuidString.prefix(8).lowercased(), + ] + var owned: [RemoteSessionReference] = [] + var nativeNames: [String] = [] + var clients: [SSHPTYChannel] = [] + var states: [State] = [] + var stage = "create" + + func cleanup() async { + for client in clients { await client.disconnect() } + for reference in owned { try? await backend.terminate(reference) } + for name in nativeNames { _ = try? await execute(nativeStop(backendID, root: root, name: name)) } + _ = try? await execute("rm -rf \(try RemoteMultiplexerCommands.quote(root))") + await control.disconnect() + } + + do { + for (index, name) in names.enumerated() { + let directory = index == 0 ? "/tmp" : "/etc" + if managed { + let token = UUID() + let session = try await backend.create( + token: token, startupCommand: "cd \(directory)", shellIntegration: true, + environment: [.init(variable: "SHELL", value: "/bin/bash")]) + owned.append( + .init( + backend: backendID, session: session, hostID: hostID, + endpoint: "fixture", username: "root", ownershipToken: token)) + } else { + nativeNames.append(name) + let result = try await execute( + nativeCreate(backendID, root: root, name: name, directory: directory)) + try #require(result.exitCode == 0, "\(result.stderr)") + } + } + + // An unrelated attached client must not force a global singleton restriction. + stage = "unrelated client" + let other = State() + states.append(other) + let otherClient = other.channel() + clients.append(otherClient) + try await attach( + otherClient, state: other, fixture: fixture, backend: backend, + reference: managed ? owned[1] : nil, root: root, name: names[1]) + try await wait { other.context.directory == "/etc" } + + for pass in 0..<2 { + stage = pass == 0 ? "initial attach" : "reconnect" + let state = State() + states.append(state) + let channel = state.channel() + clients.append(channel) + let reference = managed ? owned[0] : nil + try await attach( + channel, state: state, fixture: fixture, backend: backend, + reference: reference, root: root, name: names[0]) + try await wait { + state.context.active?.identity.backend == backendID.rawValue + && state.context.directory == (pass == 0 ? "/tmp" : "/var") + && (!managed || state.context.active?.directorySource == .shell) + } + if managed { + #expect(state.context.active?.directorySource == .shell) + } else { + #expect( + state.context.active?.directorySource + == (backendID == .tmux || backendID == .zellij ? .nativePane : .process)) + #expect(state.context.active?.capabilities.isEmpty == true) + #expect(state.context.active?.historySource == nil) + } + + stage = "cd and revalidate, pass \(pass + 1)" + let nextDirectory = pass == 0 ? "/var" : "/usr" + await channel.write(Data("cd \(nextDirectory)\r".utf8)) + try await wait { + state.context.directory == nextDirectory + && (!managed + || (state.context.active?.promptReady == true + && state.context.active?.capabilities.contains(.directory) == true)) + } + let expected = try #require(state.context.active) + do { + try await channel.confirmContext(expected) + } catch { + print("Revalidation expected: \(expected)\nLatest: \(String(describing: state.context.active))") + if let client = state.context.clientIdentity, UInt64(client) != nil { + let tty = try await execute("ps -p \(client) -o tty= | tr -d ' \\n'") + let observed = try? await RemoteMultiplexerRuntimeAdapter( + attachment: reference, execute: execute + ).inspect(terminal: "/dev/" + tty.stdout) + print("Fresh observation: \(String(describing: observed))") + } + throw error + } + #expect(other.context.directory == "/etc") + + if pass == 0 { + stage = "pane change" + try await verifyPaneChange( + backendID, channel: channel, state: state, reference: reference, root: root, + name: names[0], execute: execute) + // A transport loss must leave the native session available to the next client. + await channel.disconnect() + } else { + stage = "detach" + await channel.write(Data(detachKeys(backendID))) + if !managed { + try await wait { + state.context.status == .ordinary && state.context.active?.promptReady == true + } + #expect(state.context.directory == "/root") + } + await channel.disconnect() + } + } + stage = "owned close" + for client in clients { await client.disconnect() } + for reference in owned { + try await backend.terminate(reference) + #expect(try await backend.sessions().allSatisfy { !backend.matches(reference, session: $0) }) + } + owned.removeAll() + for name in nativeNames { + let result = try await execute(nativeStop(backendID, root: root, name: name)) + #expect(result.exitCode == 0) + } + nativeNames.removeAll() + await cleanup() + } catch { + for state in states { + print( + """ + \(backendID.rawValue) \(managed ? "managed" : "manual") at \(stage): + status=\(state.context.status), issue=\(String(describing: state.context.issue)), + active=\(String(describing: state.context.active)) + \(String(decoding: state.output.suffix(4096), as: UTF8.self)) + """) + } + let diagnostic = try? await execute( + "ps -u \"$(id -u)\" -o pid= -o ppid= -o tty= -o pgid= -o tpgid= -o comm=; " + + "find /tmp/termind-shell.* -name journal -exec cat {} \\; 2>/dev/null") + if let diagnostic { print(diagnostic.stdout) } + Issue.record("\(backendID.rawValue) \(managed ? "managed" : "manual") failed at \(stage): \(error)") + await cleanup() + throw error + } + } + + private func attach( + _ channel: SSHPTYChannel, state: State, fixture: Fixture, backend: RemoteMultiplexerService, + reference: RemoteSessionReference?, root: String, name: String + ) async throws { + await channel.configureStartup { _ in + guard let reference else { return nil } + let launch = try backend.launch(reference, created: false) + return SSHPTYStartupPlan(command: launch.command, runtimeAttachment: launch.attachment) + } + try await connect(channel, fixture: fixture) + if reference == nil { + try await wait { state.context.status == .ordinary && state.context.active?.promptReady == true } + await channel.write(Data((try nativeAttach(backend.id, root: root, name: name) + "\r").utf8)) + } + } + + private func verifyPaneChange( + _ backend: SessionPersistenceBackendID, channel: SSHPTYChannel, state: State, + reference: RemoteSessionReference?, root: String, name: String, execute: RemoteSessionCommandExecutor + ) async throws { + if backend == .zmx { return } + let first = try #require(state.context.active) + let route: String + let target: String + if let reference, backend != .tmux { + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: backend) + route = try #require(RemoteMultiplexerRegistry.socketDialect(backend)).routing(identity) + target = reference.session.name + } else { + route = try nativeEnvironment(backend, root: root) + target = reference?.session.name ?? name + } + let command: String + switch backend { + case .tmux: + command = + "tmux split-window -P -F '#{pane_id}' -t \(try quote(target)) -c /opt" + + (reference == nil ? " '/bin/bash --noprofile --norc'" : "") + case .zellij: + command = "zellij --session \(try quote(target)) action new-pane" + case .herdr: + command = + "herdr --session \(try quote(target)) pane split \(try quote(first.identity.pane)) " + + "--direction right --cwd /opt --focus" + default: throw TerminalRuntimeError.unsupported + } + let created = try await execute(route + "\n" + command) + try #require(created.exitCode == 0, "\(created.stderr)") + let close: String + if backend == .herdr { + struct Response: Decodable { + struct Result: Decodable { + struct Pane: Decodable { + let paneID: String + enum CodingKeys: String, CodingKey { case paneID = "pane_id" } + } + let pane: Pane + } + let result: Result + } + let pane = try JSONDecoder().decode(Response.self, from: Data(created.stdout.utf8)).result.pane.paneID + try await wait { state.context.backend == .herdr && state.context.directory == nil } + #expect(state.context.active == nil) + close = "herdr --session \(try quote(target)) pane close \(try quote(pane))" + } else { + if backend == .zellij { + try await wait { + state.context.active?.identity.pane != nil + && state.context.active?.identity.pane != first.identity.pane + && (reference == nil || state.context.active?.promptReady == true) + } + await channel.write(Data("cd /opt\r".utf8)) + } + try await wait { + state.context.directory == "/opt" && state.context.active?.identity.pane != first.identity.pane + } + if reference != nil { + #expect(state.context.active?.directorySource == .shell) + } + let pane = try #require(state.context.active?.identity.pane) + close = + backend == .tmux + ? "tmux kill-pane -t \(try quote(pane))" + : "zellij --session \(try quote(target)) action close-pane --pane-id \(try quote(pane))" + } + let closed = try await execute(route + "\n" + close) + try #require(closed.exitCode == 0, "\(closed.stderr)") + try await wait { state.context.active?.identity == first.identity && state.context.directory == "/var" } + } + + private func nativeEnvironment(_ backend: SessionPersistenceBackendID, root: String) throws -> String { + switch backend { + case .zellij: return "export ZELLIJ_SOCKET_DIR=\(try quote(root))" + case .zmx: return "export ZMX_DIR=\(try quote(root))" + case .herdr: return "export XDG_CONFIG_HOME=\(try quote(root))" + default: return "" + } + } + + private func nativeCreate( + _ backend: SessionPersistenceBackendID, root: String, name: String, directory: String + ) throws -> String { + let prefix = + "mkdir -p \(try quote(root)); cd \(try quote(directory)) || exit 1\n" + + (try nativeEnvironment(backend, root: root)) + "\n" + switch backend { + case .tmux: + return prefix + + "tmux new-session -d -s \(try quote(name)) -c \(try quote(directory)) '/bin/bash --noprofile --norc'" + case .zellij: + return prefix + + "zellij --layout-string 'default_shell \"/bin/bash\"\nsession_serialization false\nlayout { pane; }' " + + "attach --create-background \(try quote(name))" + case .zmx: + return prefix + "zmx attach \(try quote(name)) /bin/bash --noprofile --norc /dev/null" + case .herdr: + return prefix + + "mkdir -p \(try quote(root + "/herdr"))\n" + + "printf 'onboarding = false\\n' > \(try quote(root + "/herdr/config.toml"))\n" + + "SHELL=/bin/bash HERDR_STARTUP_CWD=$PWD nohup herdr --session \(try quote(name)) server " + + "\(try quote(root + "/" + name + ".log")) 2>&1 &\n" + + "for attempt in $(seq 1 50); do [ -S \(try quote(root + "/herdr/sessions/" + name + "/herdr-client.sock")) ] " + + "&& exit 0; sleep 0.1; done; exit 1" + default: throw TerminalRuntimeError.unsupported + } + } + + private func nativeAttach(_ backend: SessionPersistenceBackendID, root: String, name: String) throws -> String { + let prefix = try nativeEnvironment(backend, root: root) + let command: String + switch backend { + case .tmux: command = "tmux attach-session -t \(try quote(name))" + case .zellij: command = "zellij attach \(try quote(name))" + case .zmx: command = "zmx attach \(try quote(name))" + case .herdr: command = "herdr --session \(try quote(name)) client" + default: throw TerminalRuntimeError.unsupported + } + return (prefix.isEmpty ? "" : prefix + "; ") + command + } + + private func nativeStop(_ backend: SessionPersistenceBackendID, root: String, name: String) throws -> String { + let prefix = try nativeEnvironment(backend, root: root) + "\n" + switch backend { + case .tmux: return prefix + "tmux kill-session -t \(try quote(name))" + case .zellij: return prefix + "zellij kill-session \(try quote(name))" + case .zmx: return prefix + "zmx kill \(try quote(name)) --force" + case .herdr: + return prefix + + "herdr session stop \(try quote(name)) --json && herdr session delete \(try quote(name)) --json" + default: throw TerminalRuntimeError.unsupported + } + } + + private func detachKeys(_ backend: SessionPersistenceBackendID) -> [UInt8] { + switch backend { + case .tmux: [0x02, 0x64] + case .zellij: [0x0F, 0x64] + case .zmx: [0x1C] + case .herdr: [0x02, 0x71] + default: [] + } + } + + private func quote(_ value: String) throws -> String { try RemoteMultiplexerCommands.quote(value) } + + private func connect(_ channel: SSHPTYChannel, fixture: Fixture) async throws { + try await channel.connect(using: fixture.request(), shellIntegrationEnabled: true, columns: 100, rows: 30) + } + + private func wait(_ condition: () -> Bool) async throws { + for _ in 0..<600 { + if condition() { return } + try await Task.sleep(for: .milliseconds(25)) + } + throw TerminalRuntimeError.invalidResponse + } +} diff --git a/TFMTests/WorkspaceCloseConfirmationTests.swift b/TFMTests/WorkspaceCloseConfirmationTests.swift index cb9a8af5..2ddff482 100644 --- a/TFMTests/WorkspaceCloseConfirmationTests.swift +++ b/TFMTests/WorkspaceCloseConfirmationTests.swift @@ -20,7 +20,7 @@ struct WorkspaceCloseConfirmationTests { defer { native.close() } var prompted = false let delegate = WorkspaceWindowCloseDelegate( - model: model, confirmClose: { true }, + model: model, confirmDiscardTheme: { prompted = true return false @@ -60,7 +60,7 @@ struct WorkspaceCloseConfirmationTests { let native = makeWindow() var allowsDiscard = false let delegate = WorkspaceWindowCloseDelegate( - model: model, confirmClose: { true }, confirmDiscardTheme: { allowsDiscard }) + model: model, confirmDiscardTheme: { allowsDiscard }) defer { native.close() app.shutdownForTermination() @@ -74,41 +74,6 @@ struct WorkspaceCloseConfirmationTests { #expect(!app.hasUnsavedTerminalThemes) } - @Test - func quitAndWindowCloseUseDifferentTitlesActionsAndScope() { - let quit = WorkspaceCloseConfirmation.application.makeAlert() - let close = WorkspaceCloseConfirmation.window.makeAlert() - - #expect(quit.messageText == String(localized: AppStrings.Workspace.quitTermindConfirmation)) - #expect(quit.informativeText == String(localized: AppStrings.Workspace.quitTermindWarning)) - #expect(quit.buttons[0].title == String(localized: AppStrings.Workspace.closeTermind)) - #expect(close.messageText == String(localized: AppStrings.Workspace.closeThisWindowConfirmation)) - #expect(close.informativeText == String(localized: AppStrings.Workspace.closeWindowWarning)) - #expect(close.buttons[0].title == String(localized: AppStrings.Workspace.closeWindow)) - for alert in [quit, close] { - #expect(alert.buttons.count == 2) - #expect(alert.buttons[1].title == String(localized: AppStrings.Common.cancel)) - } - } - - @Test(arguments: [WorkspaceCloseConfirmation.window, .application]) - func closeRemainsDefaultAndCancelUsesEscapeAfterLayout(_ scope: WorkspaceCloseConfirmation) { - let alert = scope.makeAlert() - alert.layout() - - #expect(alert.buttons[0].keyEquivalent == "\r") - #expect(alert.window.defaultButtonCell === alert.buttons[0].cell) - #expect(alert.buttons[1].keyEquivalent == "\u{1b}") - } - - @Test(arguments: [WorkspaceCloseConfirmation.window, .application]) - func onlyCloseConfirmationAllowsClosing(_ scope: WorkspaceCloseConfirmation) { - #expect(scope.confirm { _ in .alertFirstButtonReturn }) - #expect(!scope.confirm { _ in .alertSecondButtonReturn }) - #expect(!scope.confirm { _ in .alertThirdButtonReturn }) - #expect(!scope.confirm { _ in .abort }) - } - @Test func quitChecksWorkspacesAcrossAllRegisteredWindows() { let app = AppModel(hosts: HostLibraryModel()) @@ -129,17 +94,15 @@ struct WorkspaceCloseConfirmationTests { } @Test - func windowVetoPreservesWorkspacesAndCanBeRetried() { + func populatedWindowClosesWithoutAPromptAndStillRespectsTheNativeDelegate() { let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example")) let model = WindowModel(workspaces: [workspace]) let native = makeWindow() let original = WindowDelegate() native.delegate = original - var approved = false - var confirmations = 0 let proxy = WorkspaceWindowCloseDelegate(model: model) { - confirmations += 1 - return approved + Issue.record("Live connections do not need a close confirmation.") + return false } proxy.attach(to: native) defer { @@ -148,16 +111,13 @@ struct WorkspaceCloseConfirmationTests { model.closeForWindow() } - #expect(native.delegate?.windowShouldClose?(native) == false) - #expect(confirmations == 1) + #expect(native.delegate?.windowShouldClose?(native) == true) #expect(!model.isClosed) #expect(model.workspaces.first === workspace) - approved = true - #expect(native.delegate?.windowShouldClose?(native) == true) - #expect(confirmations == 2) original.allowsClose = false #expect(native.delegate?.windowShouldClose?(native) == false) - #expect(confirmations == 2) + original.allowsClose = true + #expect(native.delegate?.windowShouldClose?(native) == true) #expect(original.requests == 3) let selector = #selector(WindowDelegate.windowDidResize(_:)) @@ -183,10 +143,9 @@ struct WorkspaceCloseConfirmationTests { saved = workspaces.first.map { ClosedWorkspaceRecord($0, hosts: hosts) } } let native = makeWindow() - var confirmations = 0 let proxy = WorkspaceWindowCloseDelegate(model: model) { - confirmations += 1 - return true + Issue.record("A managed session does not need a close confirmation.") + return false } proxy.attach(to: native) defer { @@ -196,9 +155,7 @@ struct WorkspaceCloseConfirmationTests { } #expect(native.delegate?.windowShouldClose?(native) == true) - #expect(confirmations == 1) #expect(!model.persistentClose.isClosing) - #expect(model.persistentClose.prompt == nil) model.closeForWindow() #expect(model.isClosed) #expect(model.workspaces.isEmpty) @@ -208,6 +165,33 @@ struct WorkspaceCloseConfirmationTests { #expect(restored.focusedPane.sshSession?.sessionPersistence.attachment == reference) } + @Test func failedManagedPaneCloseDisconnectsAndKeepsItsRecoveryReference() async throws { + let host = Host(name: "Server", host: "server.example") + let hosts = HostLibraryModel(hosts: [host]) + let workspace = Workspace.sshSession(host: host) + let session = try #require(workspace.focusedPane.sshSession) + let reference = RemoteSessionReference( + backend: .zellij, session: .init(id: "session", name: "Managed", identity: Data([1])), + hostID: host.id, endpoint: "server.example:22", username: "user", ownershipToken: UUID()) + session.sessionPersistence.attachment = reference + let model = WindowModel(workspaces: [workspace]) + var saved: ClosedWorkspaceRecord? + model.rememberClosedWorkspace = { saved = ClosedWorkspaceRecord($0, hosts: hosts) } + defer { model.closeForWindow() } + + // There is no live SSH transport to end the remote session. + model.closeSelected() + for _ in 0..<200 { + if model.workspaces.isEmpty { break } + try await Task.sleep(for: .milliseconds(5)) + } + #expect(model.workspaces.isEmpty) + #expect(session.sessionPersistence.attachment == reference) + #expect(session.status == .disconnected) + let restored = try #require(saved?.restore(in: hosts)) + #expect(restored.focusedPane.sshSession?.sessionPersistence.attachment == reference) + } + @Test func emptyWindowClosesWithoutConfirmation() { let model = WindowModel()