Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

103 lines
5.8 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
nonisolated enum RemoteTerminalDraft {
static func command(
context: TerminalShellContext, terminal: String, helper: String?,
clientIdentity: String?, request: UUID, replacement: String?, original: String? = nil,
charset: RemoteCharset = .utf8
) 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
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
"""
} else {
guard identity.backend == "shell", let helper else { throw TerminalRuntimeError.staleContext }
root = "helper=\(try RemoteTmuxCommands.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()
} ?? "-"
return """
umask 077
\(root)
case "$helper" in /tmp/termind-shell.*) ;; *) exit 79 ;; esac
suffix=${helper#/tmp/termind-shell.}
case "$suffix" in ''|*[!a-zA-Z0-9]*) exit 79 ;; esac
dir="$helper/\(identity.shellInstance)"
[ -d "$dir" ] && [ ! -L "$dir" ] || exit 79
IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79
[ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79
printf '%s|%s|%s|%s|%s|%s\\n' '\(request.uuidString)' '\(context.sequence)' \
'\(replacement == nil ? "read" : "replace")' '\(encoded)' "$(( $(date +%s) + 2 ))" '\(baseline)' > "$dir/request.tmp"
mv "$dir/request.tmp" "$dir/request"
\(trigger)
"""
}
static func resultCommand(context: TerminalShellContext, helper: String?, request: UUID) throws -> String {
let root: String
if context.identity.backend == "tmux" {
guard let session = context.identity.session.split(separator: ":").first else {
throw TerminalRuntimeError.staleContext
}
root = """
helper=$(tmux show-environment -t \(try RemoteTmuxCommands.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))"
}
return """
\(root)
case "$helper" in /tmp/termind-shell.*) ;; *) exit 79 ;; esac
suffix=${helper#/tmp/termind-shell.}
case "$suffix" in ''|*[!a-zA-Z0-9]*) exit 79 ;; esac
file="$helper/\(context.identity.shellInstance)/journal"
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if [ -f "$file" ] && grep -F '|draft|' "$file" | grep -q -F '|\(request.uuidString)'; then cat "$file"; exit 0; fi
sleep 0.1
done
exit 79
"""
}
}