Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift

343 lines
16 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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
}
private var dialect: any RemoteMultiplexerDialect {
get throws {
guard let dialect = RemoteMultiplexerRegistry.dialect(attachment?.backend ?? .tmux) else {
throw TerminalRuntimeError.unsupported
}
return dialect
}
}
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
// Preserve the native tmux TTY lookup and integrated journal path. Other backends
// can still provide a read-only directory when no Termind helper was installed.
if let attachment {
_ = try dialect
do {
let update = try await dialect.runtime(reference: attachment, execute: execute, charset: charset)
.inspect(terminal: terminal)
if update.active != nil { return update }
} catch is CancellationError {
throw CancellationError()
} catch {}
} else {
do {
let update = try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset)
.inspect(terminal: terminal)
if update.status == .multiplexer || update.backend != nil { return update }
} catch is CancellationError {
throw CancellationError()
} catch {}
}
guard let client = try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) else {
return .init(status: .unknown)
}
do {
if let attachment, client.backend == attachment.backend,
let identity = try? RemoteMultiplexerIdentity.decode(
attachment.session.identity, kind: attachment.backend),
identity.helper != nil,
client.endpoint
== (client.backend == .herdr ? identity.root + "/herdr-client.sock" : identity.socket)
{
do {
let update = try await RemoteSocketMultiplexerRuntime(
reference: attachment, execute: execute, charset: charset, boundClient: client
)
.inspect(terminal: terminal)
if update.active != nil,
try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) == client
{
return update
}
} catch is CancellationError {
throw CancellationError()
} catch {}
}
return try await RemoteNativeMultiplexerRuntime(client: client, execute: execute).inspect(
terminal: terminal)
} catch is CancellationError {
throw CancellationError()
} catch {
return .init(
status: .multiplexer, clientIdentity: client.identity, backend: client.backend,
issue: .inspectionUnavailable)
}
}
static func helper(for attachment: RemoteSessionReference?) throws -> String? {
guard let attachment else { return nil }
guard let dialect = RemoteMultiplexerRegistry.dialect(attachment.backend) else {
throw TerminalRuntimeError.unsupported
}
return try dialect.helper(attachment)
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
try await dialect.runtime(reference: attachment, execute: execute, charset: charset)
.draftTarget(context: context, terminal: terminal, replacing: replacing)
}
func refreshSize(of client: RemoteTmuxClientIdentity) async throws {
try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).refreshSize(of: client)
}
}
nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
let reference: RemoteSessionReference
let execute: RemoteSessionCommandExecutor
var charset: RemoteCharset = .utf8
var boundClient: RemoteTerminalProcessProbe.Client? = nil
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
try await snapshot(terminal: terminal).update
}
private struct Snapshot {
let update: TerminalRuntimeUpdate
let native: String
let focus: String
}
private var dialect: any RemoteSocketMultiplexerDialect {
get throws {
guard let dialect = RemoteMultiplexerRegistry.socketDialect(reference.backend) else {
throw TerminalRuntimeError.unsupported
}
return dialect
}
}
private var identity: RemoteMultiplexerIdentity {
get throws {
try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: dialect.id)
}
}
private func nativeCommands() throws -> (String, String) {
try dialect.runtimeCommands(name: RemoteMultiplexerCommands.quote(reference.session.name))
}
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
if let boundClient {
return """
helper=\(try RemoteMultiplexerCommands.quote(helper))
client='\(boundClient.process)'
\(try RemoteTerminalProcessProbe.guardClient(boundClient, terminal: terminal))
"""
}
return """
helper=\(try RemoteMultiplexerCommands.quote(helper))
\(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false))
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")"
\(Self.journalCommand(backend: try identity.kind))
"""
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, clientBound: boundClient != nil)
}
static func journalCommand(backend: SessionPersistenceBackendID) -> String {
"""
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
[ "$tag" = M2 ] && [ "$backend" = '\(backend.rawValue)' ] || continue
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ "${dir##*/}" = "$instance" ] || continue
shell_check "$pid" "$shell" "$dir" || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
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'
"""
}
static func parse(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false
) throws -> TerminalRuntimeUpdate {
try parseSnapshot(text, reference: reference, charset: charset, clientBound: clientBound).update
}
private static func parseSnapshot(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset, clientBound: Bool = false
) throws -> Snapshot {
guard text.utf8.count <= 1024 * 1024,
let dialect = RemoteMultiplexerRegistry.socketDialect(reference.backend)
else { throw TerminalRuntimeError.invalidResponse }
let kind = dialect.id
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 dialect.activePane(
native: native, focus: focus, reference: reference, clientBound: clientBound)
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, !clientBound {
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, backend: kind,
issue: active == nil ? .paneAmbiguous : nil),
native: native, focus: focus)
}
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 = try dialect.writeDraft(name: name, pane: pane, key: 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; }
""")
}
}