fix(terminal): restore multiplexer paths and silent session close
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ struct RootView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content.overlay { RemoteSessionClosePromptView(coordinator: window.persistentClose) }
|
||||
content
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user