feat(sessions): unify multiplexer backends and preserve cwd
Add Zellij, zmx, and Herdr alongside tmux using shared lifecycle, shell startup, journal, and runtime infrastructure. Keep verified shell directories visible during commands without restoring unsafe operation capabilities. Cover backend ownership, startup replay, runtime context, and desktop/mobile cwd regressions. Preserve conservative degradation where client focus cannot be verified.
This commit is contained in:
1 parent
7d77acd3ed
commit
5a74809da8
34 files changed
+2487
-215
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
nonisolated enum RemoteHerdrCommands {
|
||||
private struct Listing: Decodable {
|
||||
let sessions: [Session]
|
||||
struct Session: Decodable {
|
||||
let name: String
|
||||
let running: Bool
|
||||
let socketPath: String
|
||||
let sessionDir: String
|
||||
let connectionError: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, running
|
||||
case socketPath = "socket_path"
|
||||
case sessionDir = "session_dir"
|
||||
case connectionError = "connection_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var list: String {
|
||||
RemoteMultiplexerKind.herdr.discovery + "\n\"$exe\" session list --json"
|
||||
}
|
||||
|
||||
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
||||
let value: Listing
|
||||
do { value = try JSONDecoder().decode(Listing.self, from: Data(text.utf8)) } catch {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
return try value.sessions.compactMap { session in
|
||||
guard session.connectionError == nil else { throw RemoteSessionFailure.invalidResponse }
|
||||
guard RemoteMultiplexerCommands.validName(session.name),
|
||||
RemoteMultiplexerCommands.validPath(session.sessionDir),
|
||||
session.socketPath == session.sessionDir + "/herdr.sock"
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
guard session.running else { return nil }
|
||||
return .init(name: session.name, root: session.sessionDir, socket: session.socketPath)
|
||||
}
|
||||
}
|
||||
|
||||
static func create(name: String) -> String {
|
||||
"""
|
||||
root="${XDG_CONFIG_HOME:-$HOME/.config}/herdr/sessions"
|
||||
mkdir -p "$root" || exit 78
|
||||
[ ! -L "$root" ] || exit 79
|
||||
mkdir "$root/\(name)" || exit 79
|
||||
chmod 700 "$root/\(name)" || exit 78
|
||||
socket="$root/\(name)/herdr.sock"
|
||||
# Only the new named server inherits the wrapper; the user's config remains untouched.
|
||||
native_started=1
|
||||
SHELL="$helper/shell" HERDR_STARTUP_CWD="$PWD" nohup "$exe" --session '\(name)' server \
|
||||
</dev/null >"$meta/create-error" 2>&1 &
|
||||
attempt=0
|
||||
while [ ! -S "$socket" ]; do
|
||||
attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 78
|
||||
sleep 0.1
|
||||
done
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
nonisolated struct RemoteMultiplexerDraftTarget {
|
||||
let root: String
|
||||
let trigger: String
|
||||
}
|
||||
|
||||
public nonisolated struct RemoteMultiplexerRuntimeAdapter: TerminalRuntimeAdapter {
|
||||
private let attachment: RemoteSessionReference?
|
||||
private let execute: RemoteSessionCommandExecutor
|
||||
private let charset: RemoteCharset
|
||||
|
||||
public init(
|
||||
attachment: RemoteSessionReference? = nil, execute: @escaping RemoteSessionCommandExecutor,
|
||||
charset: RemoteCharset = .utf8
|
||||
) {
|
||||
self.attachment = attachment
|
||||
self.execute = execute
|
||||
self.charset = charset
|
||||
}
|
||||
|
||||
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
|
||||
if let attachment, attachment.backend != .tmux {
|
||||
return try await RemoteSocketMultiplexerRuntime(
|
||||
reference: attachment, execute: execute, charset: charset
|
||||
).inspect(terminal: terminal)
|
||||
}
|
||||
return try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).inspect(terminal: terminal)
|
||||
}
|
||||
|
||||
static func helper(for attachment: RemoteSessionReference?) throws -> String? {
|
||||
guard let attachment, attachment.backend != .tmux else { return nil }
|
||||
guard let kind = RemoteMultiplexerKind(rawValue: attachment.backend.rawValue) else {
|
||||
throw TerminalRuntimeError.unsupported
|
||||
}
|
||||
return try RemoteMultiplexerIdentity.decode(attachment.session.identity, kind: kind).helper
|
||||
}
|
||||
|
||||
func draftTarget(
|
||||
context: TerminalShellContext, terminal: String, replacing: Bool
|
||||
) async throws -> RemoteMultiplexerDraftTarget {
|
||||
if let attachment, attachment.backend != .tmux {
|
||||
return try await RemoteSocketMultiplexerRuntime(
|
||||
reference: attachment, execute: execute, charset: charset
|
||||
).draftTarget(context: context, terminal: terminal, replacing: replacing)
|
||||
}
|
||||
let update = try await inspect(terminal: terminal)
|
||||
guard update.active == context.identity,
|
||||
update.contexts.first(where: { $0.identity == context.identity }) == context
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
return try RemoteTmuxRuntimeDialect.draftTarget(
|
||||
context: context, terminal: terminal, clientIdentity: update.clientIdentity, replacing: replacing)
|
||||
}
|
||||
|
||||
func refreshSize(of client: RemoteTmuxClientIdentity) async throws {
|
||||
try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).refreshSize(of: client)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct RemoteSocketMultiplexerRuntime: TerminalRuntimeAdapter {
|
||||
let reference: RemoteSessionReference
|
||||
let execute: RemoteSessionCommandExecutor
|
||||
var charset: RemoteCharset = .utf8
|
||||
|
||||
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
|
||||
try await snapshot(terminal: terminal).update
|
||||
}
|
||||
|
||||
private struct Snapshot {
|
||||
let update: TerminalRuntimeUpdate
|
||||
let native: String
|
||||
let focus: String
|
||||
}
|
||||
|
||||
private var identity: RemoteMultiplexerIdentity {
|
||||
get throws {
|
||||
guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue) else {
|
||||
throw TerminalRuntimeError.unsupported
|
||||
}
|
||||
return try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
private func nativeCommands() throws -> (String, String) {
|
||||
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
|
||||
switch try identity.kind {
|
||||
case .tmux: throw TerminalRuntimeError.unsupported
|
||||
case .zellij:
|
||||
return ("\"$exe\" list-sessions --short --no-formatting", "\"$exe\" --session \(name) action list-clients")
|
||||
case .zmx: return ("\"$exe\" list", "printf 'shell'")
|
||||
case .herdr: return ("\"$exe\" session list --json", "\"$exe\" --session \(name) pane list")
|
||||
}
|
||||
}
|
||||
|
||||
private func clientGuard(terminal: String) throws -> String {
|
||||
guard RemoteTmuxClientIdentity.validTerminal(terminal), let helper = try identity.helper else {
|
||||
throw TerminalRuntimeError.unsupported
|
||||
}
|
||||
let kind = try identity.kind.rawValue
|
||||
return """
|
||||
helper=\(try RemoteMultiplexerCommands.quote(helper))
|
||||
\(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false))
|
||||
meta="$state/\(reference.session.name)"
|
||||
private_state || exit 79
|
||||
[ "$(cat "$meta/helper")" = "$helper" ] || exit 79
|
||||
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
|
||||
key=$(printf '%s' \(try RemoteMultiplexerCommands.quote(terminal)) | od -An -v -tx1 | tr -d ' \\n')
|
||||
[ ! -L "$helper" ] && [ ! -L "$helper/client-$key" ] || exit 79
|
||||
IFS='|' read -r client started < "$helper/client-$key" || exit 79
|
||||
case "$client" in ''|*[!0-9]*) exit 79 ;; esac
|
||||
[ -n "$started" ] && [ "$(process_start "$client")" = "$started" ] || exit 79
|
||||
client_tty=$(ps -p "$client" -o tty= | tr -d ' ')
|
||||
[ "/dev/$client_tty" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79
|
||||
# Without a native TTY-to-client API, any other interactive client makes focus ambiguous.
|
||||
clients=$(ps -u "$(id -u)" -o pid= -o tty= -o comm= | \
|
||||
awk '$2 != "?" && $2 != "??" {n=split($3,a,"/"); if(a[n]=="\(kind)") print $1}')
|
||||
[ "$clients" = "$client" ] || exit 79
|
||||
"""
|
||||
}
|
||||
|
||||
private var shellCheck: String {
|
||||
get throws {
|
||||
"""
|
||||
shell_check() {
|
||||
case "$1" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$2" in bash|zsh|fish) ;; *) return 1 ;; esac
|
||||
[ -d "$3" ] && [ ! -L "$3" ] && [ ! -L "$3/process-start" ] || return 1
|
||||
started=$(cat "$3/process-start") || return 1
|
||||
[ -n "$started" ] && [ "$(process_start "$1")" = "$started" ] || return 1
|
||||
parent=$(ps -p "$1" -o ppid= | tr -d ' ') || return 1
|
||||
\(try identity.process.map { "[ \"$1\" = '\($0)' ] && [ \"$parent\" = '\(try identity.daemonProcess!)' ] || return 1" } ?? "")
|
||||
parent_name=$(ps -p "$parent" -o comm=)
|
||||
[ "${parent_name##*/}" = '\(try identity.kind.rawValue)' ] || return 1
|
||||
name=$(ps -p "$1" -o comm=)
|
||||
[ "${name##*/}" = "$2" ] || [ "${name##*/}" = "-$2" ] || return 1
|
||||
}
|
||||
foreground_shell() {
|
||||
shell_check "$1" "$2" "$3" || return 1
|
||||
groups=$(ps -p "$1" -o pgid= -o tpgid=) || return 1
|
||||
set -- $groups
|
||||
[ "$#" = 2 ] && [ "$1" = "$2" ]
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshot(terminal: String) async throws -> Snapshot {
|
||||
let (nativeCommand, focusCommand) = try nativeCommands()
|
||||
let command = """
|
||||
\(try RemoteMultiplexerCommands.guardIdentity(reference))
|
||||
\(try clientGuard(terminal: terminal))
|
||||
\(try shellCheck)
|
||||
native=$(\(nativeCommand)) || exit 79
|
||||
focus=$(\(focusCommand)) || exit 79
|
||||
printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")"
|
||||
count=0
|
||||
for file in "$helper"/ctx.*/journal; do
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || continue
|
||||
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
|
||||
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
|
||||
[ "$tag" = M2 ] && [ "$backend" = '\(try identity.kind.rawValue)' ] || continue
|
||||
[ "$token" = "$(cat "$helper/event-token")" ] || continue
|
||||
dir=${file%/journal}
|
||||
[ "${dir##*/}" = "$instance" ] || continue
|
||||
shell_check "$pid" "$shell" "$dir" || continue
|
||||
foreground=0
|
||||
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
|
||||
printf 'BEGIN|%s\\n' "$foreground"
|
||||
cat "$file" || exit 78
|
||||
printf 'END\\n'
|
||||
done
|
||||
printf 'DONE\\n'
|
||||
"""
|
||||
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)
|
||||
}
|
||||
|
||||
static func parse(
|
||||
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8
|
||||
) throws -> TerminalRuntimeUpdate {
|
||||
try parseSnapshot(text, reference: reference, charset: charset).update
|
||||
}
|
||||
|
||||
private static func parseSnapshot(
|
||||
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset
|
||||
) throws -> Snapshot {
|
||||
guard text.utf8.count <= 1024 * 1024,
|
||||
let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue)
|
||||
else { throw TerminalRuntimeError.invalidResponse }
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
guard lines.count >= 5, lines[0].hasPrefix("C|"), UInt64(lines[0].dropFirst(2)) != nil,
|
||||
lines[1].hasPrefix("N|"), lines[2].hasPrefix("A|"), lines.suffix(2) == ["DONE", ""]
|
||||
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 activePane(kind: kind, native: native, focus: focus, reference: reference)
|
||||
var contexts: [TerminalShellContext] = []
|
||||
var events: [TerminalContextEvent] = []
|
||||
var active: TerminalContextIdentity?
|
||||
var index = 3
|
||||
while index < lines.count - 2 {
|
||||
guard lines[index] == "BEGIN|0" || lines[index] == "BEGIN|1" else {
|
||||
throw TerminalRuntimeError.invalidResponse
|
||||
}
|
||||
let foreground = lines[index] == "BEGIN|1"
|
||||
index += 1
|
||||
let start = index
|
||||
while index < lines.count - 2, lines[index] != "END" { index += 1 }
|
||||
guard index < lines.count - 2 else { throw TerminalRuntimeError.invalidResponse }
|
||||
let record = try RemoteShellContextRecord(lines[start..<index].joined(separator: "\n"), charset: charset)
|
||||
index += 1
|
||||
guard record.backend == kind.rawValue else { throw TerminalRuntimeError.invalidResponse }
|
||||
var context = record.context(
|
||||
server: identity.fingerprint, session: reference.session.id, backend: kind.rawValue)
|
||||
if !foreground { context.capabilities.remove(.directory) }
|
||||
if record.pane == activePane {
|
||||
guard active == nil else { throw TerminalRuntimeError.invalidResponse }
|
||||
active = context.identity
|
||||
if foreground, record.ready { context.capabilities.formUnion([.readDraft, .replaceDraft]) }
|
||||
}
|
||||
contexts.append(context)
|
||||
events.append(contentsOf: record.events(for: context))
|
||||
}
|
||||
guard contexts.count <= 128 else { throw TerminalRuntimeError.invalidResponse }
|
||||
return Snapshot(
|
||||
update: .init(
|
||||
status: .multiplexer, clientIdentity: String(lines[0].dropFirst(2)),
|
||||
active: active, contexts: contexts, events: events),
|
||||
native: native, focus: focus)
|
||||
}
|
||||
|
||||
private static func activePane(
|
||||
kind: RemoteMultiplexerKind, native: String, focus: String, reference: RemoteSessionReference
|
||||
) throws -> String? {
|
||||
switch kind {
|
||||
case .tmux: throw TerminalRuntimeError.unsupported
|
||||
case .zellij:
|
||||
// A switched client cannot be bound using its original argv. Require a unique live session too.
|
||||
guard 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)
|
||||
guard fields.count >= 3, UInt64(fields[0]) != nil, fields[1].hasPrefix("terminal_") else { return nil }
|
||||
let pane = String(fields[1].dropFirst("terminal_".count))
|
||||
return RemoteShellContextRecord.validPane(pane, backend: "zellij") ? pane : nil
|
||||
case .zmx:
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
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,
|
||||
session.name == reference.session.name, session.process == identity.process,
|
||||
session.created == identity.created, session.clients == 1
|
||||
else { return nil }
|
||||
return "shell"
|
||||
case .herdr:
|
||||
let sessions = try RemoteHerdrCommands.parseList(native)
|
||||
guard sessions.count == 1, sessions.first?.name == reference.session.name else { return nil }
|
||||
struct Response: Decodable {
|
||||
struct Result: Decodable {
|
||||
struct Pane: Decodable {
|
||||
let paneID: String
|
||||
enum CodingKeys: String, CodingKey { case paneID = "pane_id" }
|
||||
}
|
||||
let panes: [Pane]
|
||||
}
|
||||
let result: Result
|
||||
}
|
||||
let response = try JSONDecoder().decode(Response.self, from: Data(focus.utf8))
|
||||
// Herdr's pane-current is global, not a per-client focus API.
|
||||
guard response.result.panes.count == 1, let pane = response.result.panes.first?.paneID,
|
||||
RemoteShellContextRecord.validPane(pane, backend: "herdr")
|
||||
else { return nil }
|
||||
// The supported CLI only exposes global pane state, not the attached client's
|
||||
// session/focus binding (including endpoint switching). Do not infer it from ps.
|
||||
// Journals remain useful for lifecycle events; active-context operations stay disabled.
|
||||
_ = pane
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func draftTarget(
|
||||
context: TerminalShellContext, terminal: String, replacing: Bool
|
||||
) async throws -> RemoteMultiplexerDraftTarget {
|
||||
let snapshot = try await snapshot(terminal: terminal)
|
||||
guard snapshot.update.active == context.identity,
|
||||
snapshot.update.contexts.first(where: { $0.identity == context.identity }) == context,
|
||||
context.capabilities.contains(replacing ? .replaceDraft : .readDraft),
|
||||
let helper = try identity.helper
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
let (nativeCommand, focusCommand) = try nativeCommands()
|
||||
let guardCommand = """
|
||||
\(try RemoteMultiplexerCommands.guardIdentity(reference))
|
||||
\(try clientGuard(terminal: terminal))
|
||||
[ "$(\(nativeCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.native)) ] || exit 79
|
||||
[ "$(\(focusCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.focus)) ] || exit 79
|
||||
"""
|
||||
let key = try RemoteMultiplexerCommands.quote(replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~")
|
||||
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
|
||||
let pane = try RemoteMultiplexerCommands.quote(context.identity.pane)
|
||||
let write: String
|
||||
switch try identity.kind {
|
||||
case .tmux: throw TerminalRuntimeError.unsupported
|
||||
case .zellij: write = "\"$exe\" --session \(name) action write-chars --pane-id \(pane) \(key)"
|
||||
case .zmx: write = "\"$exe\" send \(name) \(key)"
|
||||
case .herdr: write = "\"$exe\" --session \(name) pane send-text \(pane) \(key)"
|
||||
}
|
||||
return .init(
|
||||
root: guardCommand + "\nhelper=\(try RemoteMultiplexerCommands.quote(helper))",
|
||||
trigger: """
|
||||
(
|
||||
\(guardCommand)
|
||||
\(try shellCheck)
|
||||
IFS='|' read -r tag token pane instance shell home pid history backend < "$dir/meta" || exit 79
|
||||
[ "$backend" = '\(context.identity.backend)' ] && [ "$pane" = \(pane) ] \
|
||||
&& [ "$instance" = '\(context.identity.shellInstance)' ] || exit 79
|
||||
foreground_shell "$pid" "$shell" "$dir" || exit 79
|
||||
IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79
|
||||
[ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79
|
||||
\(write) >/dev/null || exit 79
|
||||
) || { rm -f "$dir/request"; exit 79; }
|
||||
""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
/// A single service entry point; command dialects retain their native identity contracts.
|
||||
public nonisolated struct RemoteMultiplexerService: RemoteSessionBackend {
|
||||
public typealias Executor = RemoteSessionCommandExecutor
|
||||
public let id: SessionPersistenceBackendID
|
||||
private let execute: Executor
|
||||
|
||||
public init(backend: SessionPersistenceBackendID = .tmux, execute: @escaping Executor) {
|
||||
id = backend
|
||||
self.execute = execute
|
||||
}
|
||||
|
||||
private var backend: RemoteMultiplexerBackend {
|
||||
get throws {
|
||||
guard let kind = RemoteMultiplexerKind(rawValue: id.rawValue) else {
|
||||
throw RemoteSessionFailure.unsupported
|
||||
}
|
||||
return .init(kind: kind, execute: execute)
|
||||
}
|
||||
}
|
||||
|
||||
public var capabilities: RemoteSessionCapabilities {
|
||||
(try? backend.capabilities) ?? .init()
|
||||
}
|
||||
|
||||
public func probe() async throws -> RemoteSessionCapabilities { try await backend.probe() }
|
||||
public func sessions() async throws -> [RemoteSessionDescriptor] { try await backend.sessions() }
|
||||
public func validate(_ reference: RemoteSessionReference) throws { try backend.validate(reference) }
|
||||
public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
|
||||
(try? backend.matches(reference, session: session)) ?? false
|
||||
}
|
||||
public func create(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = []
|
||||
) async throws -> RemoteSessionDescriptor {
|
||||
try await backend.create(
|
||||
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, environment: environment)
|
||||
}
|
||||
public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
|
||||
try backend.launch(attachment, created: created)
|
||||
}
|
||||
public func terminate(_ attachment: RemoteSessionReference) async throws { try await backend.terminate(attachment) }
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
nonisolated enum RemoteMultiplexerKind: String, Codable, Sendable {
|
||||
case tmux, zellij, zmx, herdr
|
||||
|
||||
var id: SessionPersistenceBackendID { .init(rawValue: rawValue) }
|
||||
|
||||
func validateVersion(_ output: String) throws {
|
||||
if self == .tmux { return try RemoteTmuxCommands.validateVersion(output) }
|
||||
let fields = output.split(whereSeparator: \.isWhitespace)
|
||||
guard fields.count >= 2, fields[0].lowercased() == rawValue else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
let components = fields[1].split(separator: ".")
|
||||
guard components.count == 3, let major = Int(components[0]), let minor = Int(components[1]),
|
||||
let patch = Int(components[2]), major >= 0, minor >= 0, patch >= 0
|
||||
else { throw RemoteSessionFailure.unsupported }
|
||||
let supported: Bool
|
||||
// Keep vvterm's supported baselines, not guessed theoretical minimums.
|
||||
// zmx 0.7 adds ownership labels; Zellij 0.44.3 and Herdr 0.8.2 include lifecycle fixes.
|
||||
switch self {
|
||||
case .tmux: supported = false
|
||||
case .zellij: supported = major > 0 || minor > 44 || (minor == 44 && patch >= 3)
|
||||
case .zmx: supported = major > 0 || minor >= 7
|
||||
case .herdr:
|
||||
supported = major > 0 || minor > 8 || (minor == 8 && patch >= 2) || (minor == 7 && patch >= 5)
|
||||
}
|
||||
guard supported else { throw RemoteSessionFailure.unsupported }
|
||||
}
|
||||
|
||||
var discovery: String {
|
||||
"""
|
||||
export LC_ALL=C
|
||||
exe=$(command -v \(rawValue) 2>/dev/null)
|
||||
case "$exe" in /*) ;; *) exe= ;; esac
|
||||
if [ -z "$exe" ]; then
|
||||
for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin "$HOME/.local/bin" "$HOME/.cargo/bin"; do
|
||||
if [ -f "$dir/\(rawValue)" ] && [ -x "$dir/\(rawValue)" ]; then exe="$dir/\(rawValue)"; break; fi
|
||||
done
|
||||
fi
|
||||
[ -n "$exe" ] && [ -x "$exe" ] || exit 77
|
||||
unset ZELLIJ ZELLIJ_PANE_ID ZELLIJ_SESSION_NAME ZMX_SESSION ZMX_SESSION_PREFIX
|
||||
unset HERDR_SESSION HERDR_SOCKET_PATH HERDR_CLIENT_SOCKET_PATH HERDR_PANE_ID HERDR_PANE_RUNTIME_ID
|
||||
unset HERDR_ACTIVE_PANE_ID HERDR_ACTIVE_TAB_ID HERDR_ACTIVE_WORKSPACE_ID HERDR_ACTIVE_PANE_CWD
|
||||
unset HERDR_WORKSPACE_ID HERDR_TAB_ID HERDR_STARTUP_CWD HERDR_BIN_PATH
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct RemoteMultiplexerIdentity: Codable, Equatable, Sendable {
|
||||
var version = 1
|
||||
let kind: RemoteMultiplexerKind
|
||||
let root: String
|
||||
let socket: String
|
||||
let fingerprint: String
|
||||
let process: String?
|
||||
let created: String?
|
||||
let owner: UUID?
|
||||
let helper: String?
|
||||
let isolated: Bool
|
||||
var directoryFingerprint: String? = nil
|
||||
var daemonProcess: String? = nil
|
||||
var daemonStarted: String? = nil
|
||||
|
||||
static func decode(_ data: Data, kind: RemoteMultiplexerKind) throws -> Self {
|
||||
let value: Self
|
||||
do { value = try JSONDecoder().decode(Self.self, from: data) } catch {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
guard value.version == 1, value.kind == kind, kind != .tmux else {
|
||||
throw RemoteSessionFailure.unsupported
|
||||
}
|
||||
guard RemoteMultiplexerCommands.validPath(value.root), RemoteMultiplexerCommands.validPath(value.socket),
|
||||
value.socket.hasPrefix(value.root + "/"),
|
||||
value.fingerprint.split(separator: ":").count == 3,
|
||||
value.fingerprint.utf8.allSatisfy({ (48...57).contains($0) || $0 == 58 }),
|
||||
value.helper.map(RemoteMultiplexerCommands.validHelper) ?? true,
|
||||
value.process.map({ UInt64($0).map { $0 > 1 } ?? false }) ?? true,
|
||||
value.created.map({ UInt64($0) != nil }) ?? true,
|
||||
kind != .zmx || (value.process != nil && value.created != nil),
|
||||
kind != .zmx
|
||||
|| (value.daemonProcess.flatMap(UInt64.init).map { $0 > 1 } == true
|
||||
&& value.daemonStarted?.isEmpty == false
|
||||
&& value.daemonStarted!.utf8.allSatisfy { (32...126).contains($0) }),
|
||||
value.directoryFingerprint.map({
|
||||
$0.split(separator: ":").count == 2
|
||||
&& $0.utf8.allSatisfy { (48...57).contains($0) || $0 == 58 }
|
||||
}) ?? true,
|
||||
kind == .zellij || !value.isolated
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct RemoteMultiplexerCandidate {
|
||||
let name: String
|
||||
let root: String
|
||||
let socket: String?
|
||||
var process: String? = nil
|
||||
var created: String? = nil
|
||||
var clients: Int? = nil
|
||||
var isolated = false
|
||||
}
|
||||
|
||||
nonisolated enum RemoteMultiplexerCommands {
|
||||
static func quote(_ value: String) throws -> String {
|
||||
guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse }
|
||||
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
static func validPath(_ value: String) -> Bool {
|
||||
value.hasPrefix("/") && value.utf8.count <= 4096
|
||||
&& !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
|
||||
&& !value.split(separator: "/").contains("..")
|
||||
}
|
||||
|
||||
static func validHelper(_ value: String) -> Bool {
|
||||
value.hasPrefix("/tmp/termind-shell.") && value.count > "/tmp/termind-shell.".count
|
||||
&& value.dropFirst("/tmp/termind-shell.".count).utf8.allSatisfy {
|
||||
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|
||||
}
|
||||
}
|
||||
|
||||
static func validName(_ value: String) -> Bool {
|
||||
!value.isEmpty && value.utf8.count <= 128 && value.first != "-"
|
||||
&& value.utf8.allSatisfy {
|
||||
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|
||||
|| [45, 46, 95].contains($0)
|
||||
} && value != "." && value != ".."
|
||||
}
|
||||
|
||||
static func managedName(_ token: UUID) -> String {
|
||||
"termind-"
|
||||
+ SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8).map {
|
||||
String(format: "%02x", $0)
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func unhex(_ value: String, limit: Int = 8192) throws -> String {
|
||||
guard value.count.isMultiple(of: 2), value.count <= limit else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
var bytes: [UInt8] = []
|
||||
var cursor = value.startIndex
|
||||
while cursor != value.endIndex {
|
||||
let end = value.index(cursor, offsetBy: 2)
|
||||
guard let byte = UInt8(value[cursor..<end], radix: 16) else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
bytes.append(byte)
|
||||
cursor = end
|
||||
}
|
||||
guard let result = String(bytes: bytes, encoding: .utf8) else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static let utilities = #"""
|
||||
hex() { printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n'; }
|
||||
process_start() { LC_ALL=C TZ=UTC ps -p "$1" -o lstart= | sed 's/^ *//;s/ *$//'; }
|
||||
fingerprint() {
|
||||
[ -S "$1" ] && [ ! -L "$1" ] || return 1
|
||||
stat -c '%d:%i:%Z' "$1" 2>/dev/null || stat -f '%d:%i:%c' "$1"
|
||||
}
|
||||
private_dir() {
|
||||
[ -d "$1" ] || (umask 077; mkdir "$1") || return 1
|
||||
[ -d "$1" ] && [ ! -L "$1" ] || return 1
|
||||
owner=$(stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1") || return 1
|
||||
[ "$owner" = "$(id -u)" ] && chmod 700 "$1"
|
||||
}
|
||||
directory_fingerprint() {
|
||||
[ -d "$1" ] && [ ! -L "$1" ] || return 1
|
||||
stat -c '%d:%i' "$1" 2>/dev/null || stat -f '%d:%i' "$1"
|
||||
}
|
||||
private_state() {
|
||||
for path in "/tmp/termind-sessions-$(id -u)" "$state" "$meta"; do
|
||||
[ -d "$path" ] && [ ! -L "$path" ] || return 1
|
||||
[ "$(stat -c '%u' "$path" 2>/dev/null || stat -f '%u' "$path")" = "$(id -u)" ] || return 1
|
||||
done
|
||||
for field in owner helper identity; do
|
||||
[ -f "$meta/$field" ] && [ ! -L "$meta/$field" ] || return 1
|
||||
done
|
||||
}
|
||||
"""#
|
||||
|
||||
static func stateRoot(_ kind: RemoteMultiplexerKind, create: Bool) -> String {
|
||||
"""
|
||||
state="/tmp/termind-sessions-$(id -u)"
|
||||
\(create ? "private_dir \"$state\" || exit 78" : "")
|
||||
state="$state/\(kind.rawValue)"
|
||||
\(create ? "private_dir \"$state\" || exit 78" : "")
|
||||
"""
|
||||
}
|
||||
|
||||
static func environment(_ values: [HostEnvironmentVariable]) throws -> String {
|
||||
try values.map { value in
|
||||
guard value.isValid, !value.variable.hasPrefix("TERMIND_"),
|
||||
!["TMUX", "ZELLIJ", "ZMX", "HERDR"].contains(where: {
|
||||
value.variable == $0 || value.variable.hasPrefix($0 + "_")
|
||||
})
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
return "export \(value.variable)=\(try quote(value.value))"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func routing(_ identity: RemoteMultiplexerIdentity) throws -> String {
|
||||
switch identity.kind {
|
||||
case .tmux: throw RemoteSessionFailure.unsupported
|
||||
case .zellij:
|
||||
return """
|
||||
export ZELLIJ_SOCKET_DIR=\(try quote(identity.root))
|
||||
\(identity.isolated ? "export XDG_CACHE_HOME=\(try quote(identity.root + "/cache"))" : "")
|
||||
"""
|
||||
case .zmx: return "export ZMX_DIR=\(try quote(identity.root))"
|
||||
case .herdr:
|
||||
let root = identity.root
|
||||
let config: String
|
||||
if root.hasSuffix("/herdr") {
|
||||
config = String(root.dropLast("/herdr".count))
|
||||
} else if let range = root.range(of: "/herdr/sessions/", options: .backwards),
|
||||
validName(String(root[range.upperBound...]))
|
||||
{
|
||||
config = String(root[..<range.lowerBound])
|
||||
} else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
guard validPath(config) else { throw RemoteSessionFailure.invalidResponse }
|
||||
return "export XDG_CONFIG_HOME=\(try quote(config))"
|
||||
}
|
||||
}
|
||||
|
||||
static func guardIdentity(_ reference: RemoteSessionReference) throws -> String {
|
||||
guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue),
|
||||
reference.version == 1, validName(reference.session.name)
|
||||
else { throw RemoteSessionFailure.unsupported }
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
try validateRoute(identity, name: reference.session.name)
|
||||
if let owner = reference.ownershipToken, owner != identity.owner {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
let owned =
|
||||
try reference.ownershipToken.map { token in
|
||||
let labelGuard =
|
||||
kind == .zmx
|
||||
? """
|
||||
[ "$("$exe" get \(try quote(reference.session.name)) termind_owner)" = '\(token.uuidString)' ] || exit 79
|
||||
[ "$(cat "$meta/native" 2>/dev/null)" = '\(identity.process!):\(identity.created!)' ] || exit 79
|
||||
""" : ""
|
||||
return """
|
||||
\(stateRoot(kind, create: false))
|
||||
meta="$state/\(reference.session.name)"
|
||||
private_state || exit 79
|
||||
[ "$(cat "$meta/owner" 2>/dev/null)" = '\(token.uuidString)' ] || exit 79
|
||||
[ "$(cat "$meta/identity" 2>/dev/null)" = \(try quote(identity.fingerprint)) ] || exit 79
|
||||
\(labelGuard)
|
||||
"""
|
||||
} ?? ""
|
||||
return """
|
||||
\(utilities)
|
||||
\(kind.discovery)
|
||||
\(try routing(identity))
|
||||
[ "$(fingerprint \(try quote(identity.socket)))" = \(try quote(identity.fingerprint)) ] || exit 79
|
||||
\(kind == .zmx ? """
|
||||
[ "$(\(RemoteZmxCommands.nativeIdentity(name: reference.session.name)))" = '\(identity.process!):\(identity.created!)' ] || exit 79
|
||||
[ "$(ps -p '\(identity.process!)' -o ppid= | tr -d ' ')" = '\(identity.daemonProcess!)' ] || exit 79
|
||||
[ "$(process_start '\(identity.daemonProcess!)')" = \(try quote(identity.daemonStarted!)) ] || exit 79
|
||||
""" : "")
|
||||
\(owned)
|
||||
"""
|
||||
}
|
||||
|
||||
static func validateRoute(_ identity: RemoteMultiplexerIdentity, name: String) throws {
|
||||
let valid: Bool
|
||||
switch identity.kind {
|
||||
case .tmux: valid = false
|
||||
case .zmx: valid = identity.socket == identity.root + "/" + name
|
||||
case .zellij: valid = identity.socket.hasSuffix("/" + name)
|
||||
case .herdr:
|
||||
valid =
|
||||
identity.socket == identity.root + "/herdr.sock"
|
||||
&& (name == "default"
|
||||
? identity.root.hasSuffix("/herdr") : identity.root.hasSuffix("/herdr/sessions/" + name))
|
||||
}
|
||||
guard valid else { throw RemoteSessionFailure.invalidResponse }
|
||||
_ = try routing(identity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared ownership and framing policy; native command dialects remain backend-owned.
|
||||
nonisolated struct RemoteMultiplexerBackend: Sendable {
|
||||
let kind: RemoteMultiplexerKind
|
||||
let execute: RemoteSessionCommandExecutor
|
||||
|
||||
var capabilities: RemoteSessionCapabilities {
|
||||
.init(windowCount: kind == .tmux, clientCount: kind == .tmux || kind == .zmx, shellIntegration: true)
|
||||
}
|
||||
|
||||
func probe() async throws -> RemoteSessionCapabilities {
|
||||
let versionFlag = kind == .tmux ? "-V" : kind == .zmx ? "version" : "--version"
|
||||
let result = try await execute(kind.discovery + "\n\"$exe\" \(versionFlag)")
|
||||
try Self.check(result)
|
||||
try kind.validateVersion(result.stdout)
|
||||
return capabilities
|
||||
}
|
||||
|
||||
static func check(_ result: RemoteSessionCommandResult) throws {
|
||||
guard !result.truncated, result.stdout.utf8.count <= 1024 * 1024 else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
switch result.exitCode {
|
||||
case 0: return
|
||||
case 77: throw RemoteSessionFailure.missing
|
||||
case 79: throw RemoteSessionFailure.ownershipMismatch
|
||||
case 80: throw RemoteSessionFailure.unsupported
|
||||
case 81: throw RemoteSessionFailure.startupUnavailable
|
||||
default: throw RemoteSessionFailure.commandFailed(result.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func validate(_ reference: RemoteSessionReference) throws {
|
||||
guard reference.version == 1, reference.backend == kind.id else { throw RemoteSessionFailure.unsupported }
|
||||
if kind == .tmux {
|
||||
_ = try RemoteTmuxCommands.attach(reference)
|
||||
return
|
||||
}
|
||||
guard reference.version == 1, reference.backend == kind.id,
|
||||
RemoteMultiplexerCommands.validName(reference.session.name)
|
||||
else { throw RemoteSessionFailure.unsupported }
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
try RemoteMultiplexerCommands.validateRoute(identity, name: reference.session.name)
|
||||
guard reference.session.id == (identity.isolated ? "managed:" : "user:") + reference.session.name,
|
||||
reference.ownershipToken == nil || reference.ownershipToken == identity.owner,
|
||||
reference.ownershipToken.map({ RemoteMultiplexerCommands.managedName($0) == reference.session.name })
|
||||
?? true
|
||||
else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
}
|
||||
|
||||
func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
|
||||
if kind == .tmux {
|
||||
guard (try? validate(reference)) != nil, reference.session.id == session.id,
|
||||
let saved = try? TmuxSessionIdentity.decode(reference.session.identity),
|
||||
let current = try? TmuxSessionIdentity.decode(session.identity)
|
||||
else { return false }
|
||||
return saved.created == current.created && saved.serverPID == current.serverPID
|
||||
&& (reference.ownershipToken.map { current.owner == $0.uuidString } ?? true)
|
||||
}
|
||||
guard (try? validate(reference)) != nil, reference.session.id == session.id,
|
||||
let saved = try? RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind),
|
||||
let current = try? RemoteMultiplexerIdentity.decode(session.identity, kind: kind)
|
||||
else { return false }
|
||||
return saved.root == current.root && saved.socket == current.socket
|
||||
&& saved.fingerprint == current.fingerprint && saved.process == current.process
|
||||
&& saved.created == current.created
|
||||
&& saved.directoryFingerprint == current.directoryFingerprint
|
||||
&& saved.daemonProcess == current.daemonProcess && saved.daemonStarted == current.daemonStarted
|
||||
&& (reference.ownershipToken == nil || reference.ownershipToken == current.owner)
|
||||
}
|
||||
|
||||
func sessions() async throws -> [RemoteSessionDescriptor] {
|
||||
let command: String
|
||||
switch kind {
|
||||
case .tmux: command = RemoteTmuxCommands.list
|
||||
case .zellij: command = RemoteZellijCommands.list
|
||||
case .zmx: command = RemoteZmxCommands.list
|
||||
case .herdr: command = RemoteHerdrCommands.list
|
||||
}
|
||||
let response = try await execute(command)
|
||||
try Self.check(response)
|
||||
let candidates: [RemoteMultiplexerCandidate]
|
||||
switch kind {
|
||||
case .tmux: return try RemoteTmuxCommands.parseSessions(response.stdout)
|
||||
case .zellij: candidates = try RemoteZellijCommands.parseList(response.stdout)
|
||||
case .zmx: candidates = try RemoteZmxCommands.parseList(response.stdout)
|
||||
case .herdr: candidates = try RemoteHerdrCommands.parseList(response.stdout)
|
||||
}
|
||||
guard candidates.count <= 256, Set(candidates.map { "\($0.isolated):\($0.name)" }).count == candidates.count
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
guard !candidates.isEmpty else { return [] }
|
||||
let commands = try candidates.enumerated().map { index, candidate -> String in
|
||||
guard RemoteMultiplexerCommands.validName(candidate.name),
|
||||
RemoteMultiplexerCommands.validPath(candidate.root)
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
let socket: String
|
||||
if let path = candidate.socket {
|
||||
guard RemoteMultiplexerCommands.validPath(path), path.hasPrefix(candidate.root + "/") else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
socket = "socket=\(try RemoteMultiplexerCommands.quote(path))"
|
||||
} else {
|
||||
socket = """
|
||||
socket=$(find \(try RemoteMultiplexerCommands.quote(candidate.root)) -type s -name \
|
||||
\(try RemoteMultiplexerCommands.quote(candidate.name)) -print 2>/dev/null) || exit 78
|
||||
"""
|
||||
}
|
||||
return """
|
||||
\(socket)
|
||||
fp=$(fingerprint "$socket") || exit 79
|
||||
owner=-; helper=-; directory=-; daemon=-; born=-
|
||||
meta="$state/\(candidate.name)"
|
||||
if private_state && [ "$(cat "$meta/identity" 2>/dev/null)" = "$fp" ]; then
|
||||
owner=$(cat "$meta/owner") || exit 78
|
||||
helper=$(cat "$meta/helper") || exit 78
|
||||
\(kind == .zmx ? """
|
||||
export ZMX_DIR=\(try RemoteMultiplexerCommands.quote(candidate.root))
|
||||
if [ "$("$exe" get '\(candidate.name)' termind_owner)" != "$owner" ] \
|
||||
|| [ "$(cat "$meta/native" 2>/dev/null)" != '\(candidate.process!):\(candidate.created!)' ]; then
|
||||
owner=-; helper=-
|
||||
fi
|
||||
""" : "")
|
||||
fi
|
||||
\(kind == .herdr ? "directory=$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(candidate.root))) || exit 79" : "")
|
||||
\(kind == .zmx ? """
|
||||
# zmx's list PID is the PTY child, not the daemon. Bind its parent separately.
|
||||
daemon=$(ps -p '\(candidate.process!)' -o ppid= | tr -d ' ') || exit 79
|
||||
comm=$(ps -p "$daemon" -o comm=)
|
||||
[ "${comm##*/}" = zmx ] || exit 79
|
||||
born=$(process_start "$daemon")
|
||||
[ -n "$born" ] || exit 79
|
||||
born=$(hex "$born")
|
||||
""" : "")
|
||||
printf 'F|\(index)|%s|%s|%s|%s|%s|%s|%s\\n' "$(hex "$socket")" "$fp" "$owner" "$helper" "$directory" "$daemon" "$born"
|
||||
"""
|
||||
}.joined(separator: "\n")
|
||||
let metadata = try await execute(
|
||||
kind.discovery + "\n" + RemoteMultiplexerCommands.utilities + "\n"
|
||||
+ RemoteMultiplexerCommands.stateRoot(kind, create: false)
|
||||
+ "\n" + commands)
|
||||
try Self.check(metadata)
|
||||
let lines = metadata.stdout.split(separator: "\n")
|
||||
guard metadata.stdout.hasSuffix("\n"), lines.count == candidates.count else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
return try lines.enumerated().map { index, line in
|
||||
let fields = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard fields.count == 9, fields[0] == "F", fields[1] == String(index),
|
||||
fields[4] == "-" || UUID(uuidString: fields[4]) != nil,
|
||||
fields[5] == "-" || RemoteMultiplexerCommands.validHelper(fields[5])
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
let candidate = candidates[index]
|
||||
let identity = RemoteMultiplexerIdentity(
|
||||
kind: kind, root: candidate.root, socket: try RemoteMultiplexerCommands.unhex(fields[2]),
|
||||
fingerprint: fields[3], process: candidate.process, created: candidate.created,
|
||||
owner: UUID(uuidString: fields[4]), helper: fields[5] == "-" ? nil : fields[5],
|
||||
isolated: candidate.isolated, directoryFingerprint: fields[6] == "-" ? nil : fields[6],
|
||||
daemonProcess: fields[7] == "-" ? nil : fields[7],
|
||||
daemonStarted: fields[8] == "-" ? nil : try RemoteMultiplexerCommands.unhex(fields[8]))
|
||||
let data = try JSONEncoder().encode(identity)
|
||||
_ = try RemoteMultiplexerIdentity.decode(data, kind: kind)
|
||||
try RemoteMultiplexerCommands.validateRoute(identity, name: candidate.name)
|
||||
return RemoteSessionDescriptor(
|
||||
id: (candidate.isolated ? "managed:" : "user:") + candidate.name,
|
||||
name: candidate.name, identity: data, clients: candidate.clients)
|
||||
}
|
||||
}
|
||||
|
||||
func create(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable]
|
||||
) async throws -> RemoteSessionDescriptor {
|
||||
if kind == .tmux {
|
||||
let result = try await execute(
|
||||
RemoteTmuxCommands.create(
|
||||
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration,
|
||||
environment: environment))
|
||||
try Self.check(result)
|
||||
let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout)
|
||||
guard confirmed.count == 1, let session = confirmed.first,
|
||||
try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString
|
||||
else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
return session
|
||||
}
|
||||
let name = RemoteMultiplexerCommands.managedName(token)
|
||||
if let existing = try await sessions().first(where: { $0.name == name }) {
|
||||
let identity = try RemoteMultiplexerIdentity.decode(existing.identity, kind: kind)
|
||||
guard identity.owner == token else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
// An interrupted response may leave a confirmed incarnation without its ready marker.
|
||||
// Only finish that same incarnation; never replay a startup command or adopt by name.
|
||||
let reference = RemoteSessionReference(
|
||||
backend: kind.id, session: existing, hostID: UUID(), endpoint: "", username: "", ownershipToken: token)
|
||||
let completion = try await execute(
|
||||
"""
|
||||
\(try RemoteMultiplexerCommands.guardIdentity(reference))
|
||||
helper=$(cat "$meta/helper") || exit 79
|
||||
\(startupCommand?.isEmpty == false ? """
|
||||
[ -f "$helper/started" ] && [ ! -L "$helper/started" ] \
|
||||
&& [ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81
|
||||
""" : "")
|
||||
: > "$meta/ready"
|
||||
printf 'TERMIND_CREATED\\n'
|
||||
""")
|
||||
try Self.check(completion)
|
||||
guard completion.stdout == "TERMIND_CREATED\n" else { throw RemoteSessionFailure.invalidResponse }
|
||||
return existing
|
||||
}
|
||||
let native: String
|
||||
switch kind {
|
||||
case .tmux: throw RemoteSessionFailure.unsupported
|
||||
case .zellij: native = RemoteZellijCommands.create(name: name)
|
||||
case .zmx: native = RemoteZmxCommands.create(name: name, token: token)
|
||||
case .herdr: native = RemoteHerdrCommands.create(name: name)
|
||||
}
|
||||
let requireStartup = startupCommand?.isEmpty == false
|
||||
let shell =
|
||||
try environment.last(where: { $0.variable == "SHELL" }).map {
|
||||
try RemoteMultiplexerCommands.quote($0.value)
|
||||
} ?? "\"${SHELL:-/bin/sh}\""
|
||||
let result = try await execute(
|
||||
"""
|
||||
export TERMIND_SHELL_LOCALE_SET="${LC_ALL+x}" TERMIND_SHELL_LOCALE="${LC_ALL-}"
|
||||
\(kind.discovery)
|
||||
\(RemoteMultiplexerCommands.utilities)
|
||||
\(RemoteMultiplexerCommands.stateRoot(kind, create: true))
|
||||
umask 077
|
||||
lock="$state/\(name).lock"
|
||||
mkdir "$lock" 2>/dev/null || exit 79
|
||||
reserved=0; native_started=0
|
||||
cleanup() {
|
||||
code=$?
|
||||
if [ "$reserved" = 1 ] && [ "$native_started" = 0 ]; then
|
||||
rm -f "$meta/owner" "$meta/helper" "$meta/create-error"
|
||||
rmdir "$meta" 2>/dev/null
|
||||
fi
|
||||
rmdir "$lock" 2>/dev/null
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 78' HUP INT TERM
|
||||
session_shell=\(shell)
|
||||
\(requireStartup ? "case \"$session_shell\" in */bash|*/zsh|*/fish) [ -x \"$session_shell\" ] || exit 81 ;; *) exit 81 ;; esac" : "")
|
||||
\(try RemoteShellStartup.install(
|
||||
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, backend: kind.rawValue,
|
||||
environment: environment))
|
||||
meta="$state/\(name)"
|
||||
# A reservation with no confirmed incarnation is not permission to adopt a same-named session.
|
||||
mkdir "$meta" 2>/dev/null || exit 79
|
||||
reserved=1
|
||||
chmod 700 "$meta" || exit 78
|
||||
printf '%s\\n' '\(token.uuidString)' > "$meta/owner" || exit 78
|
||||
printf '%s\\n' "$helper" > "$meta/helper" || exit 78
|
||||
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL="$session_shell"
|
||||
\(native)
|
||||
fp=$(fingerprint "$socket") || exit 79
|
||||
printf '%s\\n' "$fp" > "$meta/identity" || exit 78
|
||||
\(requireStartup ? """
|
||||
attempt=0
|
||||
while [ ! -f "$helper/started" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 50 ]; then
|
||||
# A slow or bypassed user rc must not run the snippet after reporting failure.
|
||||
mkdir "$helper/startup-decision" 2>/dev/null && exit 81
|
||||
[ -f "$helper/started" ] || exit 81
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81
|
||||
""" : "")
|
||||
: > "$meta/ready"
|
||||
printf 'TERMIND_CREATED\\n'
|
||||
""")
|
||||
try Self.check(result)
|
||||
guard result.stdout == "TERMIND_CREATED\n",
|
||||
let session = try await sessions().first(where: { $0.name == name }),
|
||||
try RemoteMultiplexerIdentity.decode(session.identity, kind: kind).owner == token
|
||||
else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
return session
|
||||
}
|
||||
|
||||
func launch(_ reference: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
|
||||
try validate(reference)
|
||||
if kind == .tmux {
|
||||
return .init(
|
||||
command: try RemoteTmuxCommands.attach(reference), attachment: reference, created: created)
|
||||
}
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
|
||||
let attach: String
|
||||
switch kind {
|
||||
case .tmux: throw RemoteSessionFailure.unsupported
|
||||
case .zellij: attach = "\"$exe\" attach \(name)"
|
||||
case .zmx:
|
||||
// attach is an upsert. If the daemon disappears after validation, its replacement must exit,
|
||||
// not become an unrelated interactive shell (or replay the original startup command).
|
||||
attach = "\"$exe\" attach \(name) /bin/sh -c 'exit 79'"
|
||||
case .herdr: attach = "\"$exe\" --session \(name) client"
|
||||
}
|
||||
let noResurrection =
|
||||
kind == .zellij
|
||||
? """
|
||||
# An attach command can otherwise resurrect a cached session after a disconnect race.
|
||||
cache="/tmp/termind-zellij-attach-cache-$(id -u)"
|
||||
private_dir "$cache" || exit 79
|
||||
export XDG_CACHE_HOME="$cache"
|
||||
""" : ""
|
||||
let bind: String
|
||||
if let helper = identity.helper {
|
||||
bind = """
|
||||
helper=\(try RemoteMultiplexerCommands.quote(helper))
|
||||
tty=$(tty) || exit 79
|
||||
key=$(printf '%s' "$tty" | od -An -v -tx1 | tr -d ' \\n')
|
||||
printf '%s|%s\\n' "$$" "$(process_start "$$")" > "$helper/client-$key"
|
||||
"""
|
||||
} else {
|
||||
bind = ""
|
||||
}
|
||||
return .init(
|
||||
command: try RemoteMultiplexerCommands.guardIdentity(reference) + "\n" + noResurrection
|
||||
+ "\n" + bind + "\nexec " + attach,
|
||||
attachment: reference, created: created)
|
||||
}
|
||||
|
||||
func terminate(_ reference: RemoteSessionReference) async throws {
|
||||
try validate(reference)
|
||||
guard reference.isManaged, kind != .herdr || reference.session.name != "default" else {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
if kind == .tmux {
|
||||
let result = try await execute(RemoteTmuxCommands.terminate(reference))
|
||||
try Self.check(result)
|
||||
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
return
|
||||
}
|
||||
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
|
||||
if kind == .herdr, identity.directoryFingerprint == nil {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
|
||||
let stop: String
|
||||
switch kind {
|
||||
case .tmux: throw RemoteSessionFailure.unsupported
|
||||
case .zellij: stop = "\"$exe\" kill-session \(name)"
|
||||
case .zmx: stop = "\"$exe\" kill \(name) --force"
|
||||
case .herdr: stop = "\"$exe\" session stop \(name) --json"
|
||||
}
|
||||
let result = try await execute(
|
||||
"""
|
||||
\(try RemoteMultiplexerCommands.guardIdentity(reference))
|
||||
\(stop) >/dev/null || exit 78
|
||||
attempt=0
|
||||
while [ -S \(try RemoteMultiplexerCommands.quote(identity.socket)) ]; do
|
||||
attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 79
|
||||
sleep 0.1
|
||||
done
|
||||
\(kind == .herdr ? """
|
||||
private_state || exit 79
|
||||
[ "$(cat "$meta/owner")" = '\(reference.ownershipToken!.uuidString)' ] || exit 79
|
||||
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
|
||||
[ "$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(identity.root)))" = '\(identity.directoryFingerprint!)' ] || exit 79
|
||||
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.socket)) ] || exit 79
|
||||
"$exe" session delete \(name) --json >/dev/null || exit 78
|
||||
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.root)) ] || exit 79
|
||||
""" : "")
|
||||
printf 'TERMIND_ENDED\\n'
|
||||
""")
|
||||
try Self.check(result)
|
||||
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.invalidResponse }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
extension RemoteSessionBackendRegistry {
|
||||
public nonisolated static var live: Self {
|
||||
Self(
|
||||
Dictionary(
|
||||
uniqueKeysWithValues: SessionPersistenceBackendID.supported.map {
|
||||
id -> (SessionPersistenceBackendID, Factory) in
|
||||
(id, { execute in RemoteMultiplexerService(backend: id, execute: execute) })
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -76,22 +76,34 @@ nonisolated enum RemoteShellContextJournal {
|
||||
fi
|
||||
"""#
|
||||
|
||||
static func initialization(shell: String, token: String) -> String {
|
||||
static func initialization(shell: String, token: String, backend: String? = nil) -> String {
|
||||
let paneVariable: String?
|
||||
switch backend {
|
||||
case "zellij": paneVariable = "ZELLIJ_PANE_ID"
|
||||
case "herdr": paneVariable = "HERDR_PANE_ID"
|
||||
case "zmx": paneVariable = nil
|
||||
default: paneVariable = "TMUX_PANE"
|
||||
}
|
||||
let tag = backend == nil ? "M1" : "M2"
|
||||
let suffix = backend.map { "|\($0)" } ?? ""
|
||||
if shell == "fish" {
|
||||
return """
|
||||
if set -q __termind_context_dir; return; end
|
||||
set -gx TERMIND_CONTEXT_TOKEN '\(token)'
|
||||
set -gx __termind_context_dir (command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX")
|
||||
test -n "$__termind_context_dir"; or return
|
||||
\(backend == nil ? "" : """
|
||||
command env LC_ALL=C TZ=UTC ps -p "$fish_pid" -o lstart= | string trim > "$__termind_context_dir/process-start"
|
||||
""")
|
||||
set -l pane shell
|
||||
if set -q TMUX_PANE; set pane "$TMUX_PANE"; end
|
||||
\(paneVariable.map { "if set -q \($0); set pane \"$\($0)\"; end" } ?? "")
|
||||
set -l history_name fish
|
||||
if set -q fish_history; set history_name "$fish_history"; end
|
||||
set -l history_base "$HOME/.local/share"
|
||||
if set -q XDG_DATA_HOME; set history_base "$XDG_DATA_HOME"; end
|
||||
set -l history_path ''
|
||||
if test -n "$history_name"; set history_path "$history_base/fish/$history_name"_history; end
|
||||
printf 'M1|%s|%s|%s|fish|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \
|
||||
printf '\(tag)|%s|%s|%s|fish|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \
|
||||
(command basename "$__termind_context_dir") (string escape --style=url -- "$HOME") "$fish_pid" \
|
||||
(string escape --style=url -- "$history_path") > "$__termind_context_dir/meta"
|
||||
function __termind_event
|
||||
@@ -104,18 +116,22 @@ nonisolated enum RemoteShellContextJournal {
|
||||
export TERMIND_CONTEXT_TOKEN='\(token)'
|
||||
__termind_context_dir=$(command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") || return
|
||||
export __termind_context_dir
|
||||
\(backend == nil ? "" : """
|
||||
command env LC_ALL=C TZ=UTC ps -p "$$" -o lstart= | sed 's/^ *//;s/ *$//' > "$__termind_context_dir/process-start"
|
||||
""")
|
||||
__termind_event() { command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" "$@"; }
|
||||
__termind_home=$(printf '%s' "$HOME" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g')
|
||||
__termind_history=${HISTFILE:-}
|
||||
case "$__termind_history" in ''|/*) ;; *) __termind_history="$PWD/$__termind_history" ;; esac
|
||||
__termind_history=$(printf '%s' "$__termind_history" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g')
|
||||
printf 'M1|%s|%s|%s|\(shell)|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "${TMUX_PANE:-shell}" \
|
||||
printf '\(tag)|%s|%s|%s|\(shell)|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "\(paneVariable.map { "${\($0):-shell}" } ?? "shell")" \
|
||||
"${__termind_context_dir##*/}" "$__termind_home" "$$" "$__termind_history" > "$__termind_context_dir/meta"
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct RemoteShellContextRecord {
|
||||
let backend: String
|
||||
let token: String
|
||||
let pane: String
|
||||
let instance: String
|
||||
@@ -136,8 +152,13 @@ nonisolated struct RemoteShellContextRecord {
|
||||
guard lines.count >= 2 else { throw TerminalRuntimeError.invalidResponse }
|
||||
let meta = lines[0].split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
let state = lines[1].split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard (7...8).contains(meta.count), meta[0] == "M1", UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil,
|
||||
meta[2] == "shell" || Self.validPane(meta[2]),
|
||||
let legacy = (7...8).contains(meta.count) && meta.first == "M1"
|
||||
let modern = meta.count == 9 && meta.first == "M2"
|
||||
guard legacy || modern else { throw TerminalRuntimeError.invalidResponse }
|
||||
let backend = modern ? meta[8] : (meta[2] == "shell" ? "shell" : "tmux")
|
||||
guard ["shell", "tmux", "zellij", "zmx", "herdr"].contains(backend),
|
||||
UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil,
|
||||
Self.validPane(meta[2], backend: backend),
|
||||
meta[3].hasPrefix("ctx."),
|
||||
meta[3].utf8.allSatisfy({
|
||||
$0 == 46 || (48...57).contains($0)
|
||||
@@ -148,6 +169,7 @@ nonisolated struct RemoteShellContextRecord {
|
||||
["0", "1"].contains(state[2])
|
||||
else { throw TerminalRuntimeError.invalidResponse }
|
||||
token = meta[1]
|
||||
self.backend = backend
|
||||
pane = meta[2]
|
||||
instance = meta[3]
|
||||
shell = meta[4]
|
||||
@@ -174,7 +196,7 @@ nonisolated struct RemoteShellContextRecord {
|
||||
}
|
||||
let home = try decode(meta[5])
|
||||
self.home = home.hasPrefix("/") ? home : nil
|
||||
let history = try meta.count == 8 ? decode(meta[7]) : ""
|
||||
let history = try meta.count >= 8 ? decode(meta[7]) : ""
|
||||
historyPath =
|
||||
(history.isEmpty || history.hasPrefix("/"))
|
||||
&& !history.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
|
||||
@@ -214,6 +236,21 @@ nonisolated struct RemoteShellContextRecord {
|
||||
value.hasPrefix("%") && value.count > 1 && value.dropFirst().utf8.allSatisfy { (48...57).contains($0) }
|
||||
}
|
||||
|
||||
static func validPane(_ value: String, backend: String) -> Bool {
|
||||
switch backend {
|
||||
case "shell", "zmx": return value == "shell"
|
||||
case "tmux": return validPane(value)
|
||||
case "zellij": return !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
|
||||
case "herdr":
|
||||
return !value.isEmpty && value.utf8.count <= 128
|
||||
&& value.utf8.allSatisfy {
|
||||
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|
||||
|| [45, 58, 95].contains($0)
|
||||
}
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func context(server: String, session: String, backend: String) -> TerminalShellContext {
|
||||
TerminalShellContext(
|
||||
identity: .init(backend: backend, server: server, session: session, pane: pane, shellInstance: instance),
|
||||
|
||||
+39
-8
@@ -1,17 +1,21 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
/// Private, per-session startup files. Nothing is ever typed into an attached terminal.
|
||||
nonisolated enum RemoteTmuxShellStartup {
|
||||
nonisolated enum RemoteShellStartup {
|
||||
static func install(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false,
|
||||
backend: String? = nil, environment: [HostEnvironmentVariable] = []
|
||||
) throws -> String {
|
||||
let eventToken = ordinary ? token : UUID()
|
||||
let bootstrap = RemoteShellIntegrationBootstrap(token: eventToken.uuidString)
|
||||
let startup = try RemoteTmuxCommands.quote(startupCommand ?? "")
|
||||
let startup = try RemoteMultiplexerCommands.quote(startupCommand ?? "")
|
||||
let reportsStartup = shellIntegration && startupCommand?.isEmpty == false
|
||||
let posixStartup = """
|
||||
if [ "${TERMIND_TMUX_FIRST:-}" = 1 ]; then
|
||||
unset TERMIND_TMUX_FIRST
|
||||
\(backend == nil ? "" : "mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null || return")
|
||||
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
|
||||
\(reportsStartup ? "__termind_startup_time=$(date +%s); __termind_event start \"$(__termind_encode \(startup))\"" : "")
|
||||
eval \(startup)
|
||||
\(reportsStartup ? "__termind_startup_status=$?; __termind_event finish \"$__termind_startup_status\" \"$(( ($(date +%s) - __termind_startup_time) * 1000 ))\"" : "")
|
||||
@@ -20,6 +24,8 @@ nonisolated enum RemoteTmuxShellStartup {
|
||||
let fishStartup = """
|
||||
if test "$TERMIND_TMUX_FIRST" = 1
|
||||
set -e TERMIND_TMUX_FIRST
|
||||
\(backend == nil ? "" : "command mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null; or return")
|
||||
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
|
||||
\(reportsStartup ? "set -g __termind_startup_time (date +%s); __termind_event start (string escape --style=url -- \(startup))" : "")
|
||||
eval \(startup)
|
||||
\(reportsStartup ? "set -l command_status $status; set -l elapsed (math (date +%s) - $__termind_startup_time); __termind_event finish $command_status (math $elapsed '*' 1000)" : "")
|
||||
@@ -27,29 +33,54 @@ nonisolated enum RemoteTmuxShellStartup {
|
||||
"""
|
||||
let bash =
|
||||
(shellIntegration
|
||||
? RemoteShellContextJournal.initialization(shell: "bash", token: eventToken.uuidString)
|
||||
? RemoteShellContextJournal.initialization(
|
||||
shell: "bash", token: eventToken.uuidString, backend: backend)
|
||||
+ "\n" + String(decoding: bootstrap.installationCommand(for: "bash", runtime: true)!, as: UTF8.self)
|
||||
: "")
|
||||
+ "\n" + (shellIntegration ? "__termind_command_ready=0\n" : "") + posixStartup
|
||||
+ (shellIntegration ? "\n__termind_report_prompt\n" : "")
|
||||
let zsh =
|
||||
(shellIntegration
|
||||
? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString)
|
||||
? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString, backend: backend)
|
||||
+ "\n" + String(decoding: bootstrap.installationCommand(for: "zsh", runtime: true)!, as: UTF8.self)
|
||||
: "")
|
||||
+ "\n" + posixStartup
|
||||
let fish =
|
||||
(shellIntegration
|
||||
? RemoteShellContextJournal.initialization(shell: "fish", token: eventToken.uuidString)
|
||||
? RemoteShellContextJournal.initialization(
|
||||
shell: "fish", token: eventToken.uuidString, backend: backend)
|
||||
+ "\n" + String(decoding: bootstrap.installationCommand(for: "fish", runtime: true)!, as: UTF8.self)
|
||||
: "")
|
||||
+ "\n" + fishStartup
|
||||
let shell = """
|
||||
#!/bin/sh
|
||||
\(backend == nil ? "" : """
|
||||
if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then
|
||||
export LC_ALL="$TERMIND_SHELL_LOCALE"
|
||||
else
|
||||
unset LC_ALL
|
||||
fi
|
||||
unset TERMIND_SHELL_LOCALE_SET TERMIND_SHELL_LOCALE
|
||||
""")
|
||||
\(backend == "zellij" ? """
|
||||
if [ "${TERMIND_SHELL_CACHE_HOME_SET:-}" = x ]; then
|
||||
export XDG_CACHE_HOME="$TERMIND_SHELL_CACHE_HOME"
|
||||
else
|
||||
unset XDG_CACHE_HOME
|
||||
fi
|
||||
unset TERMIND_SHELL_CACHE_HOME_SET TERMIND_SHELL_CACHE_HOME
|
||||
""" : "")
|
||||
\(try RemoteMultiplexerCommands.environment(environment))
|
||||
SHELL=${TERMIND_TMUX_SHELL:-${SHELL:-/bin/sh}}
|
||||
export SHELL
|
||||
if [ "$#" -gt 0 ]; then exec "$SHELL" "$@"; fi
|
||||
unset __termind_context_dir
|
||||
\(backend == nil ? "" : """
|
||||
unset TERMIND_TMUX_FIRST
|
||||
if mkdir "$TERMIND_TMUX_HELPERS/first-claimed" 2>/dev/null; then
|
||||
export TERMIND_TMUX_FIRST=1
|
||||
fi
|
||||
""")
|
||||
export TERMIND_CONTEXT_OSC=\(ordinary ? "1" : "0")
|
||||
case "$SHELL" in
|
||||
*/bash)
|
||||
@@ -76,7 +107,7 @@ nonisolated enum RemoteTmuxShellStartup {
|
||||
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n"
|
||||
var files = [
|
||||
"shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer,
|
||||
"request-reader": RemoteShellContextJournal.requestReader,
|
||||
"request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString,
|
||||
]
|
||||
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
|
||||
files[name] = """
|
||||
@@ -88,7 +119,7 @@ nonisolated enum RemoteTmuxShellStartup {
|
||||
}
|
||||
files[".zlogin", default: ""] += "\nexport ZDOTDIR=\"$TERMIND_USER_ZDOTDIR\"\n\(zsh)\n"
|
||||
let writes = try files.keys.sorted().map { name in
|
||||
"printf '%s\\n' \(try RemoteTmuxCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78"
|
||||
"printf '%s\\n' \(try RemoteMultiplexerCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78"
|
||||
}.joined(separator: "\n")
|
||||
return """
|
||||
umask 077
|
||||
@@ -58,8 +58,7 @@ nonisolated struct RemoteTerminalContextFilter {
|
||||
}
|
||||
update.contexts.append(
|
||||
.init(
|
||||
status: context.promptReady ? .ordinary : .unknown,
|
||||
active: context.promptReady ? context.identity : nil,
|
||||
status: .ordinary, active: context.identity,
|
||||
contexts: [context], events: record.events(for: context)))
|
||||
} else {
|
||||
update.contexts.append(.init(status: .unavailable))
|
||||
@@ -80,4 +79,26 @@ nonisolated struct RemoteTerminalContextFilter {
|
||||
buffer = Data(buffer[end...])
|
||||
return update
|
||||
}
|
||||
|
||||
static func needsObservation(_ update: TerminalRuntimeUpdate) -> Bool {
|
||||
update.status != .ordinary
|
||||
|| update.contexts.first(where: { $0.identity == update.active })?.promptReady != true
|
||||
}
|
||||
|
||||
static func reconcileObservation(
|
||||
_ observation: TerminalRuntimeUpdate, current: TerminalRuntimeUpdate?
|
||||
) -> 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,
|
||||
let current, current.status == .ordinary,
|
||||
let shell = current.contexts.first(where: { $0.identity == current.active }),
|
||||
shell.identity.backend == "shell", !shell.promptReady
|
||||
{
|
||||
var retained = current
|
||||
retained.events = []
|
||||
return retained
|
||||
}
|
||||
return observation
|
||||
}
|
||||
}
|
||||
@@ -6,55 +6,41 @@ nonisolated enum RemoteTerminalDraft {
|
||||
static func command(
|
||||
context: TerminalShellContext, terminal: String, helper: String?,
|
||||
clientIdentity: String?, request: UUID, replacement: String?, original: String? = nil,
|
||||
charset: RemoteCharset = .utf8
|
||||
charset: RemoteCharset = .utf8, target: RemoteMultiplexerDraftTarget? = nil
|
||||
) throws -> String {
|
||||
let identity = context.identity
|
||||
guard context.promptReady, ["bash", "zsh", "fish"].contains(context.shell),
|
||||
identity.shellInstance.hasPrefix("ctx."),
|
||||
identity.shellInstance.utf8.allSatisfy({
|
||||
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 46
|
||||
}), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true
|
||||
}), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true,
|
||||
original?.utf8.count ?? 0 <= 4096, original?.contains("\0") != true
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
let root: String
|
||||
let trigger: String
|
||||
let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~"
|
||||
if identity.backend == "tmux" {
|
||||
let session = identity.session.split(separator: ":").map(String.init)
|
||||
let client = clientIdentity?.split(separator: ":").map(String.init) ?? []
|
||||
guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil,
|
||||
UInt64(session[1]) != nil, UInt64(identity.server) != nil,
|
||||
RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal,
|
||||
UInt64(client[1]) != nil, UInt64(client[2]) != nil
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
let target = try RemoteTmuxCommands.quote(session[0])
|
||||
root = """
|
||||
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
|
||||
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
|
||||
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
|
||||
= '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79
|
||||
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79
|
||||
helper=${helper#TERMIND_TMUX_HELPERS=}
|
||||
"""
|
||||
let guardFormat =
|
||||
"#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}"
|
||||
trigger = """
|
||||
tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \
|
||||
\(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79
|
||||
"""
|
||||
if let target {
|
||||
guard RemoteMultiplexerKind(rawValue: identity.backend) != nil else {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
root = target.root
|
||||
trigger = target.trigger
|
||||
} else if identity.backend == "tmux" {
|
||||
let target = try RemoteTmuxRuntimeDialect.draftTarget(
|
||||
context: context, terminal: terminal, clientIdentity: clientIdentity, replacing: replacement != nil)
|
||||
root = target.root
|
||||
trigger = target.trigger
|
||||
} else {
|
||||
guard identity.backend == "shell", let helper else { throw TerminalRuntimeError.staleContext }
|
||||
root = "helper=\(try RemoteTmuxCommands.quote(helper))"
|
||||
root = "helper=\(try RemoteMultiplexerCommands.quote(helper))"
|
||||
// The worker sends the negotiated widget key after this request is installed.
|
||||
trigger = "printf 'READY\\n'"
|
||||
}
|
||||
let bytes = try RemoteCharsetStreamConverter.encode(replacement ?? "", charset: charset)
|
||||
guard bytes.count <= 4096 else { throw TerminalRuntimeError.staleContext }
|
||||
let encoded = bytes.map { String(format: "%%%02X", $0) }.joined()
|
||||
let baseline =
|
||||
try original.map {
|
||||
try RemoteCharsetStreamConverter.encode($0, charset: charset).map { String(format: "%%%02X", $0) }
|
||||
.joined()
|
||||
} ?? "-"
|
||||
let baselineBytes = try original.map { try RemoteCharsetStreamConverter.encode($0, charset: charset) }
|
||||
guard baselineBytes?.count ?? 0 <= 4096 else { throw TerminalRuntimeError.staleContext }
|
||||
let baseline = baselineBytes?.map { String(format: "%%%02X", $0) }.joined() ?? "-"
|
||||
return """
|
||||
umask 077
|
||||
\(root)
|
||||
@@ -79,12 +65,12 @@ nonisolated enum RemoteTerminalDraft {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
root = """
|
||||
helper=$(tmux show-environment -t \(try RemoteTmuxCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
|
||||
helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
|
||||
helper=${helper#TERMIND_TMUX_HELPERS=}
|
||||
"""
|
||||
} else {
|
||||
guard let helper else { throw TerminalRuntimeError.staleContext }
|
||||
root = "helper=\(try RemoteTmuxCommands.quote(helper))"
|
||||
root = "helper=\(try RemoteMultiplexerCommands.quote(helper))"
|
||||
}
|
||||
return """
|
||||
\(root)
|
||||
|
||||
+4
-94
@@ -1,94 +1,8 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
/// Control operations run serially before the terminal channel occupies a server session slot.
|
||||
public nonisolated struct RemoteTmuxService: RemoteSessionBackend, Sendable {
|
||||
public typealias Executor = RemoteSessionCommandExecutor
|
||||
private let execute: Executor
|
||||
public var id: SessionPersistenceBackendID { .tmux }
|
||||
public var capabilities: RemoteSessionCapabilities {
|
||||
.init(windowCount: true, clientCount: true, shellIntegration: true)
|
||||
}
|
||||
|
||||
public init(execute: @escaping Executor) { self.execute = execute }
|
||||
|
||||
public func validate(_ reference: RemoteSessionReference) throws {
|
||||
guard reference.version == 1, reference.backend == id else { throw RemoteSessionFailure.unsupported }
|
||||
_ = try TmuxSessionIdentity.decode(reference.session.identity)
|
||||
}
|
||||
|
||||
public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
|
||||
guard reference.version == 1, reference.backend == id,
|
||||
reference.session.id == session.id,
|
||||
let saved = try? TmuxSessionIdentity.decode(reference.session.identity),
|
||||
let current = try? TmuxSessionIdentity.decode(session.identity),
|
||||
saved.created == current.created, saved.serverPID == current.serverPID
|
||||
else { return false }
|
||||
return reference.ownershipToken.map { current.owner == $0.uuidString } ?? true
|
||||
}
|
||||
|
||||
public func probe() async throws -> RemoteSessionCapabilities {
|
||||
let version = try await execute("command -v tmux >/dev/null 2>&1 || exit 77; tmux -V")
|
||||
if version.exitCode == 77 { throw RemoteSessionFailure.missing }
|
||||
try Self.check(version)
|
||||
try RemoteTmuxCommands.validateVersion(version.stdout)
|
||||
return capabilities
|
||||
}
|
||||
|
||||
public func sessions() async throws -> [RemoteSessionDescriptor] {
|
||||
let result = try await execute(RemoteTmuxCommands.list)
|
||||
try Self.check(result)
|
||||
return try RemoteTmuxCommands.parseSessions(result.stdout)
|
||||
}
|
||||
|
||||
public func create(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = []
|
||||
) async throws
|
||||
-> RemoteSessionDescriptor
|
||||
{
|
||||
let result = try await execute(
|
||||
RemoteTmuxCommands.create(
|
||||
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration,
|
||||
environment: environment))
|
||||
if result.exitCode == 80 { throw RemoteSessionFailure.unsupported }
|
||||
try Self.check(result)
|
||||
let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout)
|
||||
guard confirmed.count == 1, let session = confirmed.first,
|
||||
try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString
|
||||
else {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
|
||||
RemoteSessionLaunch(
|
||||
command: try RemoteTmuxCommands.attach(attachment), attachment: attachment, created: created)
|
||||
}
|
||||
|
||||
public func terminate(_ attachment: RemoteSessionReference) async throws {
|
||||
guard attachment.isManaged else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
let result = try await execute(RemoteTmuxCommands.terminate(attachment))
|
||||
if result.exitCode == 79 { throw RemoteSessionFailure.ownershipMismatch }
|
||||
try Self.check(result)
|
||||
guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
}
|
||||
|
||||
private static func check(_ result: RemoteSessionCommandResult) throws {
|
||||
guard !result.truncated else { throw RemoteSessionFailure.invalidResponse }
|
||||
guard result.exitCode == 0 else {
|
||||
throw RemoteSessionFailure.commandFailed(result.stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteSessionBackendRegistry {
|
||||
public static var live: Self { Self([.tmux: { RemoteTmuxService(execute: $0) }]) }
|
||||
}
|
||||
|
||||
private nonisolated struct TmuxSessionIdentity: Codable {
|
||||
nonisolated struct TmuxSessionIdentity: Codable {
|
||||
var version = 1
|
||||
let created: String
|
||||
let serverPID: String
|
||||
@@ -103,8 +17,7 @@ private nonisolated struct TmuxSessionIdentity: Codable {
|
||||
|
||||
public nonisolated enum RemoteTmuxCommands {
|
||||
public static func quote(_ value: String) throws -> String {
|
||||
guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse }
|
||||
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
try RemoteMultiplexerCommands.quote(value)
|
||||
}
|
||||
|
||||
public static func validateVersion(_ output: String) throws {
|
||||
@@ -194,10 +107,7 @@ public nonisolated enum RemoteTmuxCommands {
|
||||
}
|
||||
|
||||
static func managedSessionName(token: UUID) -> String {
|
||||
// Keep 64 bits of a one-way digest: short to display, stable on retry, not an ownership credential.
|
||||
"termind-"
|
||||
+ SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8)
|
||||
.map { String(format: "%02x", $0) }.joined()
|
||||
RemoteMultiplexerCommands.managedName(token)
|
||||
}
|
||||
|
||||
public static func create(
|
||||
@@ -205,7 +115,7 @@ public nonisolated enum RemoteTmuxCommands {
|
||||
) throws -> String {
|
||||
let owner = token.uuidString
|
||||
let name = managedSessionName(token: token)
|
||||
let wrapper = try RemoteTmuxShellStartup.install(
|
||||
let wrapper = try RemoteShellStartup.install(
|
||||
token: token, startupCommand: startupCommand, shellIntegration: shellIntegration)
|
||||
guard environment.allSatisfy({ $0.isValid && !$0.variable.hasPrefix("TERMIND_") && $0.variable != "TMUX" })
|
||||
else {
|
||||
+35
-3
@@ -2,15 +2,15 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter {
|
||||
nonisolated struct RemoteTmuxRuntimeDialect: TerminalRuntimeAdapter {
|
||||
private let execute: RemoteSessionCommandExecutor
|
||||
private let charset: RemoteCharset
|
||||
public init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
|
||||
init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
|
||||
self.execute = execute
|
||||
self.charset = charset
|
||||
}
|
||||
|
||||
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
|
||||
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
|
||||
let result = try await execute(Self.command(terminal: terminal))
|
||||
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
|
||||
return try Self.parse(result.stdout, terminal: terminal, charset: charset)
|
||||
@@ -166,3 +166,35 @@ public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter {
|
||||
status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events)
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteTmuxRuntimeDialect {
|
||||
static func draftTarget(context: TerminalShellContext, terminal: String, clientIdentity: String?, replacing: Bool)
|
||||
throws -> RemoteMultiplexerDraftTarget
|
||||
{
|
||||
let identity = context.identity
|
||||
let key = replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~"
|
||||
let session = identity.session.split(separator: ":").map(String.init)
|
||||
let client = clientIdentity?.split(separator: ":").map(String.init) ?? []
|
||||
guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil,
|
||||
UInt64(session[1]) != nil, UInt64(identity.server) != nil,
|
||||
RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal,
|
||||
UInt64(client[1]) != nil, UInt64(client[2]) != nil
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
let target = try RemoteTmuxCommands.quote(session[0])
|
||||
let root = """
|
||||
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
|
||||
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
|
||||
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
|
||||
= '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79
|
||||
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79
|
||||
helper=${helper#TERMIND_TMUX_HELPERS=}
|
||||
"""
|
||||
let guardFormat =
|
||||
"#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}"
|
||||
let trigger = """
|
||||
tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \
|
||||
\(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79
|
||||
"""
|
||||
return .init(root: root, trigger: trigger)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
nonisolated enum RemoteZellijCommands {
|
||||
static let userRoot = #"""
|
||||
if [ -n "${ZELLIJ_SOCKET_DIR:-}" ]; then root=$ZELLIJ_SOCKET_DIR
|
||||
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zellij"
|
||||
else root="${TMPDIR:-/tmp}/zellij-$(id -u)"; fi
|
||||
"""#
|
||||
|
||||
static var list: String {
|
||||
"""
|
||||
\(RemoteMultiplexerKind.zellij.discovery)
|
||||
\(RemoteMultiplexerCommands.utilities)
|
||||
\(userRoot)
|
||||
user_root=$root
|
||||
for scope in user managed; do
|
||||
if [ "$scope" = user ]; then root=$user_root; else root="/tmp/termind-zellij-$(id -u)"; fi
|
||||
printf 'ROOT|%s|%s\\n' "$scope" "$(hex "$root")"
|
||||
if [ -d "$root" ]; then
|
||||
export ZELLIJ_SOCKET_DIR=$root
|
||||
listing=$("$exe" list-sessions --short --no-formatting 2>&1)
|
||||
code=$?
|
||||
if [ "$code" = 0 ]; then
|
||||
printf '%s\\n' "$listing" | while IFS= read -r name; do
|
||||
case "$name" in ''|*[!a-zA-Z0-9_.-]*|.|..) exit 78 ;; esac
|
||||
# list-sessions also includes serialized, exited sessions. Never offer those as live.
|
||||
socket=$(find "$root" -type s -name "$name" -print 2>/dev/null) || exit 78
|
||||
[ -S "$socket" ] && [ ! -L "$socket" ] || continue
|
||||
"$exe" --session "$name" action list-panes --json >/dev/null 2>&1 || continue
|
||||
printf '%s\\n' "$name"
|
||||
done || exit 78
|
||||
elif [ "$listing" != 'No active zellij sessions found.' ]; then printf '%s\\n' "$listing" >&2; exit 78; fi
|
||||
fi
|
||||
printf 'END\\n'
|
||||
done
|
||||
"""
|
||||
}
|
||||
|
||||
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
||||
guard text.hasSuffix("\n") else { throw RemoteSessionFailure.invalidResponse }
|
||||
var result: [RemoteMultiplexerCandidate] = []
|
||||
var root: String?
|
||||
var isolated = false
|
||||
var scopes: Set<String> = []
|
||||
for line in text.split(separator: "\n").map(String.init) {
|
||||
if line.hasPrefix("ROOT|") {
|
||||
let fields = line.components(separatedBy: "|")
|
||||
guard root == nil, fields.count == 3, ["user", "managed"].contains(fields[1]),
|
||||
scopes.insert(fields[1]).inserted
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
root = try RemoteMultiplexerCommands.unhex(fields[2])
|
||||
isolated = fields[1] == "managed"
|
||||
} else if line == "END" {
|
||||
guard root != nil else { throw RemoteSessionFailure.invalidResponse }
|
||||
root = nil
|
||||
} else {
|
||||
guard let root, RemoteMultiplexerCommands.validName(line) else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
result.append(.init(name: line, root: root, socket: nil, isolated: isolated))
|
||||
}
|
||||
}
|
||||
guard root == nil, scopes == ["user", "managed"] else { throw RemoteSessionFailure.invalidResponse }
|
||||
return result
|
||||
}
|
||||
|
||||
static func create(name: String) -> String {
|
||||
"""
|
||||
root="/tmp/termind-zellij-$(id -u)"
|
||||
private_dir "$root" || exit 78
|
||||
export ZELLIJ_SOCKET_DIR=$root
|
||||
export TERMIND_SHELL_CACHE_HOME_SET="${XDG_CACHE_HOME+x}" TERMIND_SHELL_CACHE_HOME="${XDG_CACHE_HOME-}"
|
||||
private_dir "$root/cache" || exit 78
|
||||
export XDG_CACHE_HOME="$root/cache"
|
||||
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
|
||||
socket=$(find "$root" -type s -name '\(name)' -print) || exit 78
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
nonisolated enum RemoteZmxCommands {
|
||||
static let root = #"""
|
||||
if [ -n "${ZMX_DIR:-}" ]; then root=$ZMX_DIR
|
||||
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zmx"
|
||||
else root="${TMPDIR:-/tmp}/zmx-$(id -u)"; fi
|
||||
"""#
|
||||
|
||||
static var list: String {
|
||||
"""
|
||||
\(RemoteMultiplexerKind.zmx.discovery)
|
||||
\(RemoteMultiplexerCommands.utilities)
|
||||
\(root)
|
||||
printf 'ROOT|%s\\n' "$(hex "$root")"
|
||||
"$exe" list || exit 78
|
||||
printf 'END\\n'
|
||||
"""
|
||||
}
|
||||
|
||||
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
||||
var lines = text.split(separator: "\n").map(String.init)
|
||||
guard text.hasSuffix("\n"), lines.count >= 2, lines[0].hasPrefix("ROOT|"), lines.last == "END" else {
|
||||
throw RemoteSessionFailure.invalidResponse
|
||||
}
|
||||
lines.removeLast()
|
||||
let root = try RemoteMultiplexerCommands.unhex(String(lines.removeFirst().dropFirst(5)))
|
||||
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") {
|
||||
guard let separator = part.firstIndex(of: "=") else { throw RemoteSessionFailure.invalidResponse }
|
||||
let key = String(part[..<separator])
|
||||
guard fields[key] == nil else { throw RemoteSessionFailure.invalidResponse }
|
||||
fields[key] = String(part[part.index(after: separator)...])
|
||||
}
|
||||
guard let name = fields["name"], RemoteMultiplexerCommands.validName(name),
|
||||
let pid = fields["pid"], UInt64(pid).map({ $0 > 1 }) == true,
|
||||
let created = fields["created"], UInt64(created) != nil,
|
||||
let clients = fields["clients"].flatMap(Int.init), clients >= 0, fields["err"] == nil
|
||||
else { throw RemoteSessionFailure.invalidResponse }
|
||||
return .init(
|
||||
name: name, root: root, socket: root + "/" + name, process: pid, created: created, clients: clients)
|
||||
}
|
||||
}
|
||||
|
||||
static func create(name: String, token: UUID) -> String {
|
||||
"""
|
||||
\(root)
|
||||
export ZMX_DIR=$root
|
||||
socket="$root/\(name)"
|
||||
[ ! -e "$socket" ] && [ ! -L "$socket" ] || exit 79
|
||||
native_started=1
|
||||
"$exe" attach '\(name)' "$helper/shell" </dev/null >/dev/null 2>"$meta/create-error" || exit 78
|
||||
"$exe" set '\(name)' 'termind_owner=\(token.uuidString)' >/dev/null || exit 78
|
||||
native=$(\(nativeIdentity(name: name))) || exit 79
|
||||
[ -n "$native" ] || exit 79
|
||||
printf '%s\\n' "$native" > "$meta/native"
|
||||
"""
|
||||
}
|
||||
|
||||
// The listed PID is the PTY child. Labels or commands containing "pid=" are not identity fields.
|
||||
static func nativeIdentity(name: String) -> String {
|
||||
"""
|
||||
"$exe" list | awk -F '\\t' -v target='\(name)' '
|
||||
{ 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++}
|
||||
if ($i ~ /^created=[0-9]+$/) {created=substr($i,9); count++}
|
||||
}
|
||||
if(name==target && count==3 && pid!="" && created!="") print pid ":" created
|
||||
}'
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -969,6 +969,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var runtimeTerminal: String?
|
||||
private var runtimeRevision: UInt64 = 0
|
||||
private var runtimeEnabled = false
|
||||
private var runtimeAttachment: RemoteSessionReference?
|
||||
private var runtimeWantsObservation = false
|
||||
private var runtimeControlUnavailable = false
|
||||
private var runtimeHelper: String?
|
||||
@@ -1512,6 +1513,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
let executor = try await sessionCommandExecutor()
|
||||
let launch = try await preparation(executor)
|
||||
try Task.checkCancellation()
|
||||
try await queue.perform { [self] in
|
||||
guard ptyResize.generation == generation else { throw CancellationError() }
|
||||
runtimeAttachment = launch?.runtimeAttachment
|
||||
runtimeHelper = try RemoteMultiplexerRuntimeAdapter.helper(for: runtimeAttachment)
|
||||
}
|
||||
if shellIntegrationEnabled {
|
||||
var probe = RemoteShellIntegrationBootstrap(charset: charset)
|
||||
if let result = try? await executor(String(decoding: probe.startCommand, as: UTF8.self)),
|
||||
@@ -1529,7 +1535,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
var startupCommand = launch?.command
|
||||
if launch == nil, shellIntegrationEnabled {
|
||||
do {
|
||||
let install = try RemoteTmuxShellStartup.install(
|
||||
let install = try RemoteShellStartup.install(
|
||||
token: runtimeToken, startupCommand: nil, shellIntegration: true, ordinary: true)
|
||||
let result = try await executor(install + "\nprintf '%s\\n' \"$helper\"\n")
|
||||
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
|
||||
@@ -1541,7 +1547,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
})
|
||||
else { throw TerminalRuntimeError.invalidResponse }
|
||||
startupCommand =
|
||||
"export TERMIND_TMUX_HELPERS=\(try RemoteTmuxCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
|
||||
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
|
||||
await queue.perform { self.runtimeHelper = helper }
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
@@ -1554,7 +1560,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
"printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original
|
||||
}
|
||||
let command = try startupCommand.map {
|
||||
SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteTmuxCommands.quote($0))).utf8))
|
||||
SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteMultiplexerCommands.quote($0))).utf8))
|
||||
}
|
||||
let runtimeIsEnabled = usesRuntime
|
||||
try await queue.perform { [self] in
|
||||
@@ -2115,7 +2121,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
try Task.checkCancellation()
|
||||
let execute = try await sessionCommandExecutor(
|
||||
runtime: true, expectedInput: inputRevision, activation: request)
|
||||
try await RemoteTmuxRuntimeAdapter(execute: execute).refreshSize(of: request.client)
|
||||
try await RemoteMultiplexerRuntimeAdapter(execute: execute).refreshSize(of: request.client)
|
||||
} catch {
|
||||
// Optional sizing assistance must not disconnect a usable terminal or inject a fallback key.
|
||||
}
|
||||
@@ -2184,7 +2190,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
for context in update.contexts {
|
||||
runtimeRevision &+= 1
|
||||
runtimeWantsObservation = context.status != .ordinary
|
||||
runtimeWantsObservation = RemoteTerminalContextFilter.needsObservation(context)
|
||||
publishRuntime(context)
|
||||
if runtimeWantsObservation { startRuntimeObservation() }
|
||||
}
|
||||
@@ -2221,23 +2227,25 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(delay))
|
||||
guard !Task.isCancelled else { break }
|
||||
let request = try? await queue.perform { [self] () -> (String, UInt64, RemoteCharset)? in
|
||||
let request = try? await queue.perform {
|
||||
[self] () -> (String, UInt64, RemoteCharset, RemoteSessionReference?)? in
|
||||
guard runtimeEnabled, runtimeWantsObservation, ptyResize.generation == generation,
|
||||
!runtimeControlUnavailable, let terminal = runtimeTerminal
|
||||
else { return nil }
|
||||
return (terminal, runtimeRevision, charset)
|
||||
return (terminal, runtimeRevision, charset, runtimeAttachment)
|
||||
}
|
||||
guard let request else { continue }
|
||||
do {
|
||||
let adapter = RemoteTmuxRuntimeAdapter(
|
||||
execute: try await sessionCommandExecutor(runtime: true), charset: request.2)
|
||||
let adapter = RemoteMultiplexerRuntimeAdapter(
|
||||
attachment: request.3, execute: try await sessionCommandExecutor(runtime: true),
|
||||
charset: request.2)
|
||||
let update = try await adapter.inspect(terminal: request.0)
|
||||
try Task.checkCancellation()
|
||||
await queue.perform { [self] in
|
||||
guard ptyResize.generation == generation, runtimeRevision == request.1,
|
||||
runtimeWantsObservation
|
||||
else { return }
|
||||
publishRuntime(update)
|
||||
publishRuntimeObservation(update)
|
||||
}
|
||||
delay = update.status == .multiplexer ? 750 : min(delay * 2, 5000)
|
||||
} catch {
|
||||
@@ -2246,7 +2254,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
guard ptyResize.generation == generation, runtimeRevision == request.1,
|
||||
runtimeWantsObservation
|
||||
else { return }
|
||||
publishRuntime(.init(status: .unavailable))
|
||||
publishRuntimeObservation(.init(status: .unavailable))
|
||||
}
|
||||
delay = min(delay * 2, 5000)
|
||||
}
|
||||
@@ -2254,6 +2262,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func publishRuntimeObservation(_ update: TerminalRuntimeUpdate) {
|
||||
publishRuntime(RemoteTerminalContextFilter.reconcileObservation(update, current: latestRuntimeUpdate))
|
||||
}
|
||||
|
||||
private func publishRuntime(_ proposed: TerminalRuntimeUpdate) {
|
||||
var update = proposed
|
||||
if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false {
|
||||
@@ -2281,12 +2293,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
guard let terminal = runtimeTerminal, latestRuntimeUpdate?.active == expected.identity else {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
return (terminal, runtimeInputRevision, ptyResize.generation, charset)
|
||||
return (terminal, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
|
||||
}
|
||||
if expected.identity.backend != "shell" {
|
||||
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.1)
|
||||
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.3).inspect(
|
||||
terminal: state.0)
|
||||
let update = try await RemoteMultiplexerRuntimeAdapter(
|
||||
attachment: state.4, execute: execute, charset: state.3
|
||||
).inspect(terminal: state.0)
|
||||
guard update.active == expected.identity,
|
||||
update.contexts.first(where: { $0.identity == expected.identity }) == expected
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
@@ -2310,14 +2323,15 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
.capabilities.contains(replacement == nil ? .readDraft : .replaceDraft) == true
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
pendingRuntimeDraft = request
|
||||
return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset)
|
||||
return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
|
||||
}
|
||||
do {
|
||||
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.2)
|
||||
let update: TerminalRuntimeUpdate?
|
||||
if expected.identity.backend == "tmux" {
|
||||
update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.4).inspect(
|
||||
terminal: state.0)
|
||||
if expected.identity.backend != "shell" {
|
||||
update = try await RemoteMultiplexerRuntimeAdapter(
|
||||
attachment: state.5, execute: execute, charset: state.4
|
||||
).inspect(terminal: state.0)
|
||||
guard update?.active == expected.identity,
|
||||
update?.contexts.first(where: { $0.identity == expected.identity }) == expected
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
@@ -2325,12 +2339,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
update = nil
|
||||
}
|
||||
try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3)
|
||||
var target: RemoteMultiplexerDraftTarget?
|
||||
if expected.identity.backend != "shell" {
|
||||
target = try await RemoteMultiplexerRuntimeAdapter(
|
||||
attachment: state.5, execute: execute, charset: state.4
|
||||
).draftTarget(context: expected, terminal: state.0, replacing: replacement != nil)
|
||||
}
|
||||
let command = try RemoteTerminalDraft.command(
|
||||
context: expected, terminal: state.0, helper: state.1,
|
||||
clientIdentity: update?.clientIdentity, request: request, replacement: replacement,
|
||||
original: try await queue.perform { [self] in
|
||||
replacement != nil && runtimeDraftBaseline?.0 == expected.identity ? runtimeDraftBaseline?.1 : nil
|
||||
}, charset: state.4)
|
||||
}, charset: state.4, target: target)
|
||||
let started = try await execute(command)
|
||||
guard started.exitCode == 0, !started.truncated else { throw TerminalRuntimeError.staleContext }
|
||||
if expected.identity.backend == "shell" {
|
||||
@@ -2496,6 +2516,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
runtimeTask = nil
|
||||
runtimeTerminal = nil
|
||||
runtimeEnabled = false
|
||||
runtimeAttachment = nil
|
||||
allowsDefaultHistorySource = true
|
||||
runtimeWantsObservation = false
|
||||
runtimeControlUnavailable = false
|
||||
@@ -2698,12 +2719,14 @@ nonisolated extension SSHPTYChannelWorker {
|
||||
guard matchesHistoryContext(expected), let terminal = runtimeTerminal else {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
return (terminal, ptyResize.generation, charset)
|
||||
return (terminal, ptyResize.generation, charset, runtimeAttachment)
|
||||
}
|
||||
if expected.identity.backend != "shell" {
|
||||
let execute = try await sessionCommandExecutor(runtime: true)
|
||||
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.2)
|
||||
.inspect(terminal: state.0)
|
||||
let update = try await RemoteMultiplexerRuntimeAdapter(
|
||||
attachment: state.3, execute: execute, charset: state.2
|
||||
)
|
||||
.inspect(terminal: state.0)
|
||||
guard update.status == .multiplexer, update.active == expected.identity,
|
||||
update.contexts.first(where: { $0.identity == expected.identity })?.historySource
|
||||
== expected.historySource
|
||||
|
||||
Reference in new issue
Block a user