Files

245 lines
14 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
private let execute: RemoteSessionCommandExecutor
private let charset: RemoteCharset
init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
self.execute = execute
self.charset = charset
}
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)
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
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 Self.draftTarget(
context: context, terminal: terminal, clientIdentity: update.clientIdentity, replacing: replacing)
}
func refreshSize(of client: RemoteTmuxClientIdentity) async throws {
let result = try await execute(Self.resizeCommand(client: client))
guard result.exitCode == 0, !result.truncated, result.stdout == "TERMIND_RESIZED\n" else {
throw TerminalRuntimeError.staleContext
}
}
static func resizeCommand(client: RemoteTmuxClientIdentity) throws -> String {
let identity = [
client.terminal, client.process, client.created, client.server, client.session, client.sessionCreated,
].joined(separator: "|")
// Revalidate on the server immediately before signalling. A detached client's TTY
// may already contain a shell, so this operation must never write terminal input.
return """
\(RemoteTmuxCommands.executionPreamble)
client=$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{client_flags}' 2>/dev/null | \
while IFS='|' read -r tty cp cc sp sid sc flags; do
if [ "$tty" = \(try RemoteTmuxCommands.quote(client.terminal)) ]; then
case ",$flags," in
(*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;;
esac
printf '%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc"
fi
done)
[ "$client" = \(try RemoteTmuxCommands.quote(identity)) ] || exit 79
kill -WINCH \(client.process) || exit 79
printf 'TERMIND_RESIZED\\n'
"""
}
static func command(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
return """
\(RemoteTmuxCommands.executionPreamble)
tmux -V || exit 77
client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \
while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do
if [ "$tty" = \(try RemoteTmuxCommands.quote(terminal)) ]; then
printf 'C|%s|%s|%s|%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc" "$pane" "$flags" "$table"
fi
done)
if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi
printf '%s\\n' "$client"
sid=$(printf '%s' "$client" | cut -d '|' -f6)
panes=$(tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}') || exit 78
printf '%s\\n' "$panes"
pane=$(printf '%s' "$client" | cut -d '|' -f8)
cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78
foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78
printf 'D|%s|%s|%s\\n' "$pane" \
"$(printf '%s' "$cwd" | od -An -v -tx1 | tr -d ' \\n')" \
"$(printf '%s' "$foreground" | od -An -v -tx1 | tr -d ' \\n')"
helper=$(tmux show-environment -t "$sid" TERMIND_TMUX_HELPERS 2>/dev/null)
helper=${helper#TERMIND_TMUX_HELPERS=}
case "$helper" in /tmp/termind-shell.*) ;; *) printf 'DONE\\n'; exit 0 ;; esac
suffix=${helper#/tmp/termind-shell.}
case "$suffix" in ''|*[!a-zA-Z0-9]*) printf 'DONE\\n'; exit 0 ;; esac
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" || continue
case "$tag:$backend" in M1:|M2:tmux) ;; *) continue ;; esac
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ ! -L "$dir" ] && [ "${dir##*/}" = "$instance" ] || continue
[ "$(printf '%s\\n' "$panes" | awk -F '|' -v pane="$pane" '$2 == pane {print $3}')" = "$pid" ] || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
printf 'BEGIN\\n'
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
"""
}
static func parse(_ text: String, terminal: String, charset: RemoteCharset = .utf8) throws -> TerminalRuntimeUpdate
{
var lines = text.components(separatedBy: "\n")
guard !lines.isEmpty else { throw TerminalRuntimeError.invalidResponse }
try RemoteTmuxCommands.validateVersion(lines.removeFirst())
if lines == ["NONE", ""] { return .init(status: .unknown) }
guard let first = lines.first else { throw TerminalRuntimeError.invalidResponse }
let client = first.components(separatedBy: "|")
guard client.count == 10, client[0] == "C", client[1] == terminal,
[2, 3, 4, 6].allSatisfy({ UInt64(client[$0]) != nil }),
client[5].hasPrefix("$"), UInt64(client[5].dropFirst()) != nil,
RemoteShellContextRecord.validPane(client[7])
else { throw TerminalRuntimeError.invalidResponse }
let flags = Set(client[8].split(separator: ",").map(String.init))
guard !flags.contains("active-pane"), !flags.contains("read-only") else {
return .init(status: .unavailable, backend: .tmux, issue: .paneAmbiguous)
}
let server = client[4]
let session = client[5] + ":" + client[6]
let clientIdentity = terminal + ":" + client[2] + ":" + client[3]
var panes: [String: (process: String, interactive: Bool, foregroundShell: String)] = [:]
var records: [RemoteShellContextRecord] = []
var nativeDirectory: String?
var nativeCommand: String?
var hasNativeDirectory = false
var index = 1
while index < lines.count {
let line = lines[index]
index += 1
if line == "DONE" {
guard index == lines.count - 1, lines.last == "" else {
throw TerminalRuntimeError.invalidResponse
}
break
}
if line.hasPrefix("D|") {
let fields = line.components(separatedBy: "|")
guard !hasNativeDirectory, fields.count == 4, fields[1] == client[7] else {
throw TerminalRuntimeError.invalidResponse
}
hasNativeDirectory = true
let directory = try RemoteMultiplexerCommands.unhex(fields[2])
nativeDirectory = RemoteMultiplexerCommands.validPath(directory) ? directory : nil
nativeCommand = try RemoteMultiplexerCommands.unhex(fields[3])
} else if line == "BEGIN" {
let start = index
while index < lines.count, lines[index] != "END" { index += 1 }
guard index < lines.count else { throw TerminalRuntimeError.invalidResponse }
records.append(
try RemoteShellContextRecord(lines[start..<index].joined(separator: "\n"), charset: charset))
index += 1
} else {
let fields = line.components(separatedBy: "|")
guard fields.count == 6, fields[0] == "P", RemoteShellContextRecord.validPane(fields[1]),
UInt64(fields[2]) != nil, ["0", "1"].contains(fields[3]), ["0", "1"].contains(fields[4]),
["bash", "zsh", "fish", "unknown"].contains(fields[5]),
panes[fields[1]] == nil
else { throw TerminalRuntimeError.invalidResponse }
panes[fields[1]] = (fields[2], fields[3] == "0" && fields[4] == "0", fields[5])
}
}
guard lines.dropLast().last == "DONE", panes[client[7]] != nil else {
throw TerminalRuntimeError.invalidResponse
}
var contexts: [TerminalShellContext] = []
var events: [TerminalContextEvent] = []
var active: TerminalContextIdentity?
let nested = RemoteNativeMultiplexerRuntime.isNested(nativeCommand)
for record in records {
guard let pane = panes[record.pane], pane.process == record.process else { continue }
var context = record.context(server: server, session: session, backend: "tmux")
if pane.interactive, pane.foregroundShell == record.shell, context.promptReady {
// A prefix key table changes input routing, not the verified pane's directory.
if client[9] == "root" {
context.capabilities.formUnion([.readDraft, .replaceDraft])
}
} else {
context.capabilities.remove(.directory)
}
contexts.append(context)
events.append(contentsOf: record.events(for: context))
if record.pane == client[7], !nested {
guard active == nil else { throw TerminalRuntimeError.invalidResponse }
active = context.identity
}
}
if active == nil, !nested {
let identity = TerminalContextIdentity(
backend: "tmux", server: server, session: session, pane: client[7],
shellInstance: "unintegrated:" + panes[client[7]]!.process)
contexts.append(
.init(
identity: identity, shell: "", directory: nativeDirectory, home: nil, promptReady: false,
sequence: 0, capabilities: [], directorySource: .nativePane))
active = identity
}
return .init(
status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events,
backend: .tmux,
issue: nested
? .nestedMultiplexer
: (active?.shellInstance.hasPrefix("unintegrated:") == true ? .shellIntegrationUnavailable : nil))
}
}
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 = """
\(RemoteTmuxCommands.executionPreamble)
[ "$(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)
}
}