fix(terminal): restore multiplexer paths and silent session close

This commit is contained in:
Kaede Akino committed 2026-10-16 22:32:31 +08:00
1 parent 4492121c6c
commit d92318a2bb
29 files changed
+1403 -721

No files matched your search

@@ -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")
}
}
}
+7 -3
View File
@@ -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("</dev/null"))
@@ -381,7 +381,11 @@ nonisolated struct RemoteMultiplexerTests {
])
let runtime = RemoteMultiplexerRuntimeAdapter(
attachment: reference, execute: { try await recorder.execute($0) })
#expect(try await runtime.inspect(terminal: "/dev/pts/1") == update)
#expect(
try await RemoteSocketMultiplexerRuntime(
reference: reference, execute: { try await recorder.execute($0) }
)
.inspect(terminal: "/dev/pts/1") == update)
let context = try #require(update.contexts.first)
if kind == .herdr {
await #expect(throws: TerminalRuntimeError.staleContext) {
+30 -27
View File
@@ -416,7 +416,7 @@ struct SessionPersistenceTests {
#expect(try await empty.sessions().isEmpty)
}
@Test func closeFailureRetainsRemainingWorkAndQuitDoesNotTerminate() async throws {
@Test func closeFailureFallsBackToDisconnectAndQuitDoesNotTerminate() async throws {
let backend = Backend()
let coordinator = RemoteSessionCoordinator()
let launch = try #require(
@@ -429,16 +429,14 @@ struct SessionPersistenceTests {
Issue.record("Quit must preserve")
})
var attempts = 0
let task = Task {
await close.close(attachments: [launch.attachment]) { _ in
attempts += 1
throw RemoteSessionFailure.ownershipMismatch
}
let allowed = await close.close(attachments: [launch.attachment]) { _ in
attempts += 1
throw RemoteSessionFailure.ownershipMismatch
}
try await wait { close.prompt?.failure != nil }
#expect(attempts == 1)
close.choose(.cancel, for: try #require(close.prompt?.id))
#expect(await task.value == false)
#expect(allowed)
#expect(!close.isClosing)
#expect(coordinator.attachment == launch.attachment)
}
@Test func explicitCloseEndsManagedSessionsWithoutAConfirmation() async {
@@ -450,13 +448,11 @@ struct SessionPersistenceTests {
var ended: [RemoteSessionReference] = []
let allowed = await close.close(attachments: [managed, external]) { session in
#expect(close.prompt == nil)
ended.append(session)
}
#expect(allowed)
#expect(ended == [managed])
#expect(close.prompt == nil)
#expect(!close.isClosing)
}
@@ -469,7 +465,6 @@ struct SessionPersistenceTests {
await close.close(attachments: [reference()], intent: intent) { _ in
Issue.record("Passive teardown must not terminate a remote session")
})
#expect(close.prompt == nil)
}
@Test func tmuxParsingKeepsNamesAsDataAndRejectsUnsupportedVersions() throws {
@@ -550,30 +545,38 @@ struct SessionPersistenceTests {
#expect(await backend.integrationRequests == [false])
}
@Test func duplicateCloseReferencesAreEndedOnceAndRetryKeepsPartialProgress() async throws {
@Test func batchCloseAttemptsEachOwnedIdentityOnceAndContinuesAfterFailure() async {
let first = reference()
var duplicate = first
duplicate.session = .init(
id: first.session.id, name: "Renamed", identity: first.session.identity, windows: 2, clients: 3)
let second = reference()
let third = reference()
let close = RemoteSessionCloseCoordinator()
var terminated: [RemoteSessionReference] = []
var fail = true
let task = Task {
await close.close(attachments: [first, duplicate, second]) { reference in
if reference == second, fail {
fail = false
throw RemoteSessionFailure.timedOut
}
terminated.append(reference)
var attempted: [RemoteSessionReference] = []
let allowed = await close.close(attachments: [first, duplicate, second, third]) { reference in
attempted.append(reference)
if reference == second {
throw RemoteSessionFailure.timedOut
}
terminated.append(reference)
}
try await wait { close.prompt?.failure != nil }
#expect(terminated == [first])
#expect(close.prompt?.names == [second.session.name])
close.choose(.endSessions, for: try #require(close.prompt?.id))
#expect(await task.value)
#expect(terminated == [first, second])
#expect(allowed)
#expect(attempted == [first, second, third])
#expect(terminated == [first, third])
}
@Test func cancelledTerminationDoesNotTurnIntoPermissionToClose() async {
let close = RemoteSessionCloseCoordinator()
var attempts = 0
let allowed = await close.close(attachments: [reference(), reference()]) { _ in
attempts += 1
throw CancellationError()
}
#expect(!allowed)
#expect(attempts == 1)
#expect(!close.isClosing)
}
@Test func cancellationDuringBatchCloseDoesNotEndAdditionalSessions() async throws {
-440
View File
@@ -15171,47 +15171,6 @@
}
}
},
"Close Termind" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind schließen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fermer Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを閉じる"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind 닫기"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭Termind"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉Termind"
}
}
}
},
"Close Transfers" : {
"extractionState" : "manual",
"localizations" : {
@@ -15499,47 +15458,6 @@
}
}
},
"Close this Window?" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieses Fenster schließen?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fermer cette fenêtre ?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このウインドウを閉じますか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 창을 닫을까요?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭此窗口?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉此視窗?"
}
}
}
},
"Collapse" : {
"extractionState" : "manual",
"localizations" : {
@@ -24842,53 +24760,6 @@
}
}
},
"Disconnect Only" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nur trennen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Disconnect Only"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Déconnecter uniquement"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "接続のみ解除"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "연결만 해제"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仅断开连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "僅中斷連線"
}
}
}
},
"Disconnected" : {
"extractionState" : "manual",
"localizations" : {
@@ -27659,147 +27530,6 @@
}
}
},
"End Managed Remote Sessions?" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verwaltete Remotesitzungen beenden?"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End Managed Remote Sessions?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminer les sessions distantes gérées ?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "管理対象のリモートセッションを終了しますか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "관리 중인 원격 세션을 종료할까요?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "结束托管远端会话?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "結束受管理的遠端工作階段?"
}
}
}
},
"End Sessions and Close" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sitzungen beenden und schließen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End Sessions and Close"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminer les sessions et fermer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "セッションを終了して閉じる"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "세션 종료 후 닫기"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "结束会话并关闭"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "結束工作階段並關閉"
}
}
}
},
"Ending these sessions stops their remote processes for all clients. Cancel keeps them running." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Das Beenden stoppt die Remoteprozesse für alle Clients. Abbrechen lässt sie weiterlaufen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ending these sessions stops their remote processes for all clients. Cancel keeps them running."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminer ces sessions arrête leurs processus distants pour tous les clients. Annuler les laisse s’exécuter."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "終了すると、すべてのクライアントのリモートプロセスが停止します。キャンセルすると実行を継続します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "세션을 종료하면 모든 클라이언트의 원격 프로세스가 중지됩니다. 취소하면 계속 실행됩니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "结束这些会话会终止所有客户端上的远端进程。取消可使其继续运行。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "結束這些工作階段會終止所有用戶端上的遠端程序。取消可讓其繼續執行。"
}
}
}
},
"Endpoint" : {
"extractionState" : "manual",
"localizations" : {
@@ -53933,47 +53663,6 @@
}
}
},
"Quit Termind?" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind beenden?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quitter Termind ?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを終了しますか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind를 종료할까요?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind?"
}
}
}
},
"Quit with unuploaded changes?" : {
"extractionState" : "manual",
"localizations" : {
@@ -54015,47 +53704,6 @@
}
}
},
"Quitting Termind will close all open Workspaces and disconnect their sessions." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Beim Beenden von Termind werden alle offenen Arbeitsbereiche geschlossen und ihre Sitzungen getrennt."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quitter Termind ferme tous les espaces de travail ouverts et déconnecte leurs sessions."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを終了すると、開いているすべてのワークスペースが閉じられ、セッションが切断されます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind를 종료하면 열려 있는 모든 작업 공간이 닫히고 해당 세션의 연결이 해제됩니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind将关闭所有打开的工作区并断开其会话连接。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind將關閉所有開啟的工作區並中斷其工作階段連線。"
}
}
}
},
"RTS/CTS" : {
"extractionState" : "manual",
"localizations" : {
@@ -77973,47 +77621,6 @@
}
}
},
"This window still has open Workspaces. Closing it will close them and disconnect their sessions." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieses Fenster enthält noch offene Arbeitsbereiche. Beim Schließen werden diese geschlossen und ihre Sitzungen getrennt."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cette fenêtre contient encore des espaces de travail ouverts. La fermer les fermera et déconnectera leurs sessions."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このウインドウには開いているワークスペースがあります。ウインドウを閉じると、それらも閉じられ、セッションが切断されます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 창에 아직 열린 작업 공간이 있습니다. 창을 닫으면 작업 공간도 닫히고 해당 세션의 연결이 해제됩니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此窗口中仍有打开的工作区。关闭窗口将关闭这些工作区并断开其中的会话连接。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此視窗中仍有開啟的工作區。關閉視窗將關閉這些工作區並中斷其中的工作階段連線。"
}
}
}
},
"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." : {
"extractionState" : "manual",
"localizations" : {
@@ -80215,53 +79822,6 @@
}
}
},
"Unable to end the remote session. Retry, or disconnect without ending it." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die Remotesitzung konnte nicht beendet werden. Versuche es erneut oder trenne die Verbindung, ohne die Sitzung zu beenden."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unable to end the remote session. Retry, or disconnect without ending it."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Impossible de terminer la session distante. Réessayez ou déconnectez-vous sans la terminer."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "リモートセッションを終了できません。再試行するか、セッションを終了せずに切断してください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "원격 세션을 종료할 수 없습니다. 다시 시도하거나 세션을 종료하지 않고 연결만 끊으세요."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无法结束远端会话。请重试,或仅断开连接而不结束会话。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "無法結束遠端工作階段。請重試,或僅中斷連線而不結束工作階段。"
}
}
}
},
"Unable to generate a secure token." : {
"extractionState" : "manual",
"localizations" : {
@@ -5,34 +5,17 @@ import TermindDomain
@MainActor
@Observable
public final class RemoteSessionCloseCoordinator {
public struct Prompt: Identifiable {
public let id = UUID()
public let names: [String]
public let failure: String?
}
public enum Choice { case cancel, endSessions, disconnectOnly }
public private(set) var prompt: Prompt?
public private(set) var isClosing = false
private var continuation: CheckedContinuation<Choice, Never>?
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
}
}
@@ -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<TerminalContextIdentity> = []
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 {
@@ -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
}
}
@@ -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))
}
}
}
@@ -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."
}
@@ -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."
}
@@ -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)"
@@ -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 }
}
@@ -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)
}
@@ -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<String> = []
var clients: Set<String> = []
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)
}
}
@@ -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
@@ -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<String> = []
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<String> = []
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
"""
}
}
@@ -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))
}
}
@@ -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)
@@ -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[..<separator])
guard fields[key] == nil else { throw RemoteSessionFailure.invalidResponse }
@@ -65,7 +67,7 @@ nonisolated enum RemoteZmxCommands {
static func nativeIdentity(name: String) -> 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 }
+33
View File
@@ -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())
+1 -1
View File
@@ -26,7 +26,7 @@ struct MobileRootView: View {
}
var body: some View {
content.overlay { RemoteSessionClosePromptView(coordinator: app.persistentClose) }
content
}
private var content: some View {
@@ -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
+1 -1
View File
@@ -26,7 +26,7 @@ struct RootView: View {
}
var body: some View {
content.overlay { RemoteSessionClosePromptView(coordinator: window.persistentClose) }
content
}
@ViewBuilder private var content: some View {
-5
View File
@@ -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()
+8 -1
View File
@@ -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) {
@@ -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 }
+3 -41
View File
@@ -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
}
@@ -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 >/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 "
+ "</dev/null >\(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
}
}
+37 -53
View File
@@ -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()