Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.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

228 lines
11 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
/// One writer per shell instance; readers see an atomic, bounded snapshot plus event tail.
nonisolated enum RemoteShellContextJournal {
static let requestReader = #"""
#!/bin/sh
dir=$1; expected=$2
[ -f "$dir/request" ] && [ ! -L "$dir/request" ] || exit 1
IFS='|' read -r request seq action value deadline original < "$dir/request" || exit 1
rm -f "$dir/request"
[ "$action" = "$expected" ] || exit 1
case "$deadline:$seq" in *[!0-9:]*|:) exit 1 ;; esac
[ "$(date +%s)" -le "$deadline" ] || exit 1
IFS='|' read -r tag current ready cwd < "$dir/state" || exit 1
[ "$seq" = "$current" ] && [ "$ready" = 1 ] || exit 1
printf '%s|%s|%s\n' "$request" "$value" "$original"
"""#
static func draftWidget(shell: String, replacing: Bool) -> String {
let action = replacing ? "replace" : "read"
if shell == "fish" {
return """
set -l response (command sh "$TERMIND_TMUX_HELPERS/request-reader" "$__termind_context_dir" \(action));
test $status -eq 0; or return;
set -l fields (string split '|' -- "$response");
if test "$fields[3]" != '-'; and test (commandline | string collect) != (string unescape --style=url -- "$fields[3]" | string collect); return; end;
\(replacing ? "commandline -r -- (string unescape --style=url -- \"$fields[2]\");" : "")
__termind_event draft (string escape --style=url -- (commandline)) "$fields[1]"
"""
}
let buffer = shell == "bash" ? "READLINE_LINE" : "BUFFER"
let cursor = shell == "bash" ? "READLINE_POINT" : "CURSOR"
return """
local response request value original;
response=$(command sh "$TERMIND_TMUX_HELPERS/request-reader" "$__termind_context_dir" \(action)) || return;
IFS='|' read -r request value original <<< "$response";
if [ "$original" != '-' ]; then
original=$(builtin printf '%b.' "$(printf '%s' "$original" | sed 's/%/\\\\x/g')");
[ "$\(buffer)" = "${original%.}" ] || return;
fi;
\(replacing ? "value=$(builtin printf '%b.' \"$(printf '%s' \"$value\" | sed 's/%/\\\\x/g')\"); \(buffer)=${value%.}; \(cursor)=${#\(buffer)};" : "")
__termind_event draft "$(__termind_encode "$\(buffer)")" "$request"
"""
}
static let writer = #"""
#!/bin/sh
set -f
dir=$1; kind=$2; value=${3-}; extra=${4-}
[ -d "$dir" ] && [ ! -L "$dir" ] || exit 1
case "$kind" in start|finish|prompt|directory|draft) ;; *) exit 1 ;; esac
[ "${#value}" -le 8192 ] || value=
[ "${#extra}" -le 8192 ] || extra=
seq=0; ready=0; cwd=
if [ -f "$dir/state" ]; then
IFS='|' read -r tag seq ready cwd < "$dir/state"
fi
seq=$((seq + 1))
case "$kind" in start) ready=0 ;; prompt) ready=1 ;; directory) cwd=$value ;; esac
printf 'R1|%s|%s|%s\n' "$seq" "$ready" "$cwd" > "$dir/state.tmp"
mv "$dir/state.tmp" "$dir/state"
{
cat "$dir/meta"
cat "$dir/state"
if [ -f "$dir/journal" ]; then sed '1,2d' "$dir/journal" | tail -n 31; fi
printf 'E1|%s|%s|%s|%s\n' "$seq" "$kind" "$value" "$extra"
} > "$dir/journal.tmp"
mv "$dir/journal.tmp" "$dir/journal"
if [ "${TERMIND_CONTEXT_OSC:-}" = 1 ]; then
printf '\033]777;TermindContext=%s;' "$TERMIND_CONTEXT_TOKEN"
cat "$dir/meta"
cat "$dir/state"
printf 'E1|%s|%s|%s|%s\n\007' "$seq" "$kind" "$value" "$extra"
fi
"""#
static func initialization(shell: String, token: String) -> String {
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
set -l pane shell
if set -q TMUX_PANE; set pane "$TMUX_PANE"; 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" \
(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
command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" $argv
end
"""
}
return """
[ -z "${__termind_context_dir:-}" ] || return
export TERMIND_CONTEXT_TOKEN='\(token)'
__termind_context_dir=$(command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") || return
export __termind_context_dir
__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}" \
"${__termind_context_dir##*/}" "$__termind_home" "$$" "$__termind_history" > "$__termind_context_dir/meta"
"""
}
}
nonisolated struct RemoteShellContextRecord {
let token: String
let pane: String
let instance: String
let shell: String
let process: String
let home: String?
let historyPath: String?
let directory: String?
let ready: Bool
let sequence: UInt64
let entries: [(UInt64, TerminalContextEvent.Payload)]
init(_ text: String, charset: RemoteCharset = .utf8) throws {
guard text.utf8.count <= 512 * 1024 else { throw TerminalRuntimeError.invalidResponse }
// PTYs may expand the record's line separators to CRLF; field data is percent encoded.
let lines = text.replacingOccurrences(of: "\r\n", with: "\n")
.split(separator: "\n", omittingEmptySubsequences: true)
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]),
meta[3].hasPrefix("ctx."),
meta[3].utf8.allSatisfy({
$0 == 46 || (48...57).contains($0)
|| (65...90).contains($0) || (97...122).contains($0)
}),
["bash", "zsh", "fish"].contains(meta[4]),
state.count == 4, state[0] == "R1", let sequence = UInt64(state[1]),
["0", "1"].contains(state[2])
else { throw TerminalRuntimeError.invalidResponse }
token = meta[1]
pane = meta[2]
instance = meta[3]
shell = meta[4]
process = meta[6]
self.sequence = sequence
ready = state[2] == "1"
func decode(_ value: String) throws -> String {
let bytes = Array(value.utf8)
var index = 0
while index < bytes.count {
if bytes[index] == 37 {
guard index + 2 < bytes.count,
bytes[(index + 1)...(index + 2)].allSatisfy({
(48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0)
})
else { throw TerminalRuntimeError.invalidResponse }
index += 3
} else {
guard bytes[index] >= 32, bytes[index] < 127 else { throw TerminalRuntimeError.invalidResponse }
index += 1
}
}
return RemoteCommandEventFilter.decodePercentEncoded(Data(bytes), charset: charset)
}
let home = try decode(meta[5])
self.home = home.hasPrefix("/") ? home : nil
let history = try meta.count == 8 ? decode(meta[7]) : ""
historyPath =
(history.isEmpty || history.hasPrefix("/"))
&& !history.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
? history : nil
let cwd = try decode(state[3])
directory = cwd.hasPrefix("/") ? cwd : nil
var events: [(UInt64, TerminalContextEvent.Payload)] = []
var last: UInt64?
for line in lines.dropFirst(2) {
let parts = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 5, parts[0] == "E1", let seq = UInt64(parts[1]), seq <= sequence,
seq > 0, last == nil || (last! < UInt64.max && seq == last! + 1)
else { throw TerminalRuntimeError.invalidResponse }
last = seq
let event: TerminalContextEvent.Payload
switch parts[2] {
case "start": event = .commandStarted(try decode(parts[3]))
case "finish":
guard let status = Int32(parts[3]), (0...255).contains(status),
let ms = UInt64(parts[4]), ms <= 31_536_000_000
else { throw TerminalRuntimeError.invalidResponse }
event = .commandFinished(status, duration: Double(ms) / 1000)
case "prompt": event = .promptReady
case "directory": event = .directory(try decode(parts[3]))
case "draft":
guard UUID(uuidString: parts[4]) != nil else { throw TerminalRuntimeError.invalidResponse }
event = .draft(request: parts[4], value: try decode(parts[3]))
default: throw TerminalRuntimeError.invalidResponse
}
events.append((seq, event))
}
guard events.count <= 32, last == sequence else { throw TerminalRuntimeError.invalidResponse }
entries = events
}
static func validPane(_ value: String) -> Bool {
value.hasPrefix("%") && value.count > 1 && value.dropFirst().utf8.allSatisfy { (48...57).contains($0) }
}
func context(server: String, session: String, backend: String) -> TerminalShellContext {
TerminalShellContext(
identity: .init(backend: backend, server: server, session: session, pane: pane, shellInstance: instance),
shell: shell, directory: directory, home: home, promptReady: ready, sequence: sequence,
capabilities: [.directory, .commandLifecycle], historyPath: historyPath)
}
func events(for context: TerminalShellContext) -> [TerminalContextEvent] {
entries.map { .init(context: context.identity, sequence: $0.0, payload: $0.1) }
}
}