// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only 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, backend: String? = nil) -> String { let paneVariable = RemoteMultiplexerRegistry.dialect(.init(rawValue: backend ?? "tmux"))?.paneVariable 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 \(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 '\(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 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 \(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 '\(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 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) 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 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) || (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] self.backend = backend 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) } } static func validPane(_ value: String, backend: String) -> Bool { if backend == "shell" { return value == "shell" } return RemoteMultiplexerRegistry.dialect(.init(rawValue: backend))?.validPane(value) ?? false } 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) } } }