219 lines
12 KiB
Swift
219 lines
12 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
/// A native directory is display-only. It never manufactures prompt or draft permissions.
|
|
nonisolated struct RemoteNativeMultiplexerRuntime {
|
|
let client: RemoteTerminalProcessProbe.Client
|
|
let execute: RemoteSessionCommandExecutor
|
|
|
|
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
|
|
guard let endpoint = client.endpoint else { return unavailable(.clientUnbound) }
|
|
let result = try await execute(command(terminal: terminal, endpoint: endpoint))
|
|
guard result.exitCode == 0, !result.truncated else { return unavailable(.inspectionUnavailable) }
|
|
let update = try Self.parse(result.stdout, client: client)
|
|
guard try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) == client else {
|
|
return unavailable(.clientUnbound)
|
|
}
|
|
return update
|
|
}
|
|
|
|
private func unavailable(_ issue: TerminalRuntimeIssue) -> TerminalRuntimeUpdate {
|
|
.init(status: .multiplexer, clientIdentity: client.identity, backend: client.backend, issue: issue)
|
|
}
|
|
|
|
private func command(terminal: String, endpoint: String) throws -> String {
|
|
let path = endpoint as NSString
|
|
let name = path.lastPathComponent
|
|
let root = path.deletingLastPathComponent
|
|
let body: String
|
|
switch client.backend {
|
|
case .zellij:
|
|
guard RemoteMultiplexerCommands.validName(name) else { throw TerminalRuntimeError.unsupported }
|
|
body = """
|
|
root=\(try RemoteMultiplexerCommands.quote(root))
|
|
release=$(printf '%s' "$version" | awk '{print $2}')
|
|
case "${root##*/}" in "$release"|contract_version_*) root=${root%/*} ;; esac
|
|
export ZELLIJ_SOCKET_DIR="$root"
|
|
focus=$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-clients) || exit 79
|
|
native=$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-panes --json) || exit 79
|
|
[ "$focus" = "$("$exe" --session \(try RemoteMultiplexerCommands.quote(name)) action list-clients)" ] || exit 79
|
|
"""
|
|
case .zmx:
|
|
guard RemoteMultiplexerCommands.validName(name) else { throw TerminalRuntimeError.unsupported }
|
|
body = """
|
|
export ZMX_DIR=\(try RemoteMultiplexerCommands.quote(root))
|
|
native=$("$exe" list) || exit 79
|
|
pid=$(printf '%s\\n' "$native" | awk -F '\\t' -v target=\(try RemoteMultiplexerCommands.quote(name)) '
|
|
{sub(/^ +/, "", $1); n=""; p=""; for(i=1;i<=NF;i++) {
|
|
if($i ~ /^name=/) n=substr($i,6)
|
|
if($i ~ /^pid=[0-9]+$/) p=substr($i,5)
|
|
} if(n==target) print p}')
|
|
case "$pid" in ''|*[!0-9]*) exit 79 ;; esac
|
|
parent=$(ps -p "$pid" -o ppid= | tr -d ' ')
|
|
parent_name=$(ps -p "$parent" -o comm=)
|
|
[ "${parent_name##*/}" = zmx ] || exit 79
|
|
born=$(process_start "$pid")
|
|
shell=$(ps -p "$pid" -o comm=); shell=${shell##*/}; shell=${shell#-}
|
|
case "$shell" in bash|zsh|fish) ;; *) printf 'NESTED\\n'; exit 0 ;; esac
|
|
tty=$(ps -p "$pid" -o tty= | tr -d ' ')
|
|
foreground=$(ps -t "$tty" -o pgid= -o tpgid= -o comm= | awk '$1==$2 && $2>0 {n=$3; sub(/^.*\\//,"",n); print n}')
|
|
case "$foreground" in *tmux*|*zellij*|*zmx*|*herdr*|ssh|mosh|et) printf 'NESTED\\n'; exit 0 ;; esac
|
|
if [ -d "/proc/$pid" ]; then directory=$(readlink "/proc/$pid/cwd")
|
|
elif command -v lsof >/dev/null 2>&1; then
|
|
directory=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p')
|
|
fi
|
|
[ -n "$born" ] && [ "$(process_start "$pid")" = "$born" ] || exit 79
|
|
focus="$pid:$born"
|
|
[ "$native" = "$("$exe" list)" ] || exit 79
|
|
"""
|
|
case .herdr:
|
|
guard name == "herdr-client.sock" else { throw TerminalRuntimeError.unsupported }
|
|
body = """
|
|
export HERDR_SOCKET_PATH=\(try RemoteMultiplexerCommands.quote(root + "/herdr.sock"))
|
|
api_fp=$(fingerprint "$HERDR_SOCKET_PATH") || exit 79
|
|
native=$("$exe" pane list) || exit 79
|
|
[ "$(fingerprint "$HERDR_SOCKET_PATH")" = "$api_fp" ] || exit 79
|
|
"""
|
|
default: throw TerminalRuntimeError.unsupported
|
|
}
|
|
let versionArgument = client.backend == .zmx ? "version" : "--version"
|
|
return """
|
|
\(RemoteMultiplexerCommands.discovery(client.backend))
|
|
\(RemoteMultiplexerCommands.utilities)
|
|
\(try RemoteTerminalProcessProbe.guardClient(client, terminal: terminal))
|
|
endpoint=\(try RemoteMultiplexerCommands.quote(endpoint))
|
|
fp=$(fingerprint "$endpoint") || exit 79
|
|
version=$("$exe" \(versionArgument)) || exit 79
|
|
native=; focus=; directory=
|
|
\(body)
|
|
[ "$(fingerprint "$endpoint")" = "$fp" ] || exit 79
|
|
\(try RemoteTerminalProcessProbe.guardClient(client, terminal: terminal))
|
|
printf 'V|%s\\nI|%s\\nN|%s\\nF|%s\\nD|%s\\nDONE\\n' \
|
|
"$(hex "$version")" "$fp" "$(hex "$native")" "$(hex "$focus")" "$(hex "$directory")"
|
|
"""
|
|
}
|
|
|
|
static func parse(_ text: String, client: RemoteTerminalProcessProbe.Client) throws -> TerminalRuntimeUpdate {
|
|
func unavailable(_ issue: TerminalRuntimeIssue) -> TerminalRuntimeUpdate {
|
|
.init(status: .multiplexer, clientIdentity: client.identity, backend: client.backend, issue: issue)
|
|
}
|
|
if text == "NESTED\n" { return unavailable(.nestedMultiplexer) }
|
|
let lines = text.components(separatedBy: "\n")
|
|
guard lines.count == 7, lines[5] == "DONE", lines[6].isEmpty,
|
|
zip(lines, ["V|", "I|", "N|", "F|", "D|"]).allSatisfy({ $0.hasPrefix($1) }),
|
|
let endpoint = client.endpoint
|
|
else { throw TerminalRuntimeError.invalidResponse }
|
|
let version = try RemoteMultiplexerCommands.unhex(String(lines[0].dropFirst(2)))
|
|
do {
|
|
try RemoteMultiplexerRegistry.dialect(client.backend)?.validateVersion(version)
|
|
} catch {
|
|
return unavailable(.unsupportedVersion)
|
|
}
|
|
let fingerprint = String(lines[1].dropFirst(2))
|
|
guard fingerprint.split(separator: ":").count == 3,
|
|
fingerprint.utf8.allSatisfy({ (48...57).contains($0) || $0 == 58 })
|
|
else { throw TerminalRuntimeError.invalidResponse }
|
|
let native = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 1024 * 1024)
|
|
let focus = try RemoteMultiplexerCommands.unhex(String(lines[3].dropFirst(2)), limit: 262144)
|
|
var directory = try RemoteMultiplexerCommands.unhex(String(lines[4].dropFirst(2)))
|
|
let pane: String
|
|
var source = TerminalDirectorySource.nativePane
|
|
switch client.backend {
|
|
case .zellij:
|
|
guard let selected = zellijPane(focus) else { return unavailable(.paneAmbiguous) }
|
|
struct Pane: Decodable {
|
|
let id: Int
|
|
let isPlugin: Bool
|
|
let paneCwd: String?
|
|
let paneCommand: String?
|
|
let exited: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, exited
|
|
case isPlugin = "is_plugin"
|
|
case paneCwd = "pane_cwd"
|
|
case paneCommand = "pane_command"
|
|
}
|
|
}
|
|
let panes = try JSONDecoder().decode([Pane].self, from: Data(native.utf8))
|
|
let matches = panes.filter { !$0.isPlugin && String($0.id) == selected && $0.exited != true }
|
|
guard matches.count == 1, let selectedPane = matches.first else { return unavailable(.paneAmbiguous) }
|
|
if isNested(selectedPane.paneCommand) { return unavailable(.nestedMultiplexer) }
|
|
pane = selected
|
|
directory = selectedPane.paneCwd ?? ""
|
|
case .zmx:
|
|
let path = endpoint as NSString
|
|
let root = path.deletingLastPathComponent.utf8.map { String(format: "%02x", $0) }.joined()
|
|
let sessions = try RemoteZmxCommands.parseList("ROOT|\(root)\n" + native + "\nEND\n")
|
|
guard let session = sessions.first(where: { $0.name == path.lastPathComponent }),
|
|
let process = session.process, focus.hasPrefix(process + ":"), session.clients.map({ $0 > 0 }) == true
|
|
else { return unavailable(.clientUnbound) }
|
|
pane = "shell"
|
|
source = .process
|
|
case .herdr:
|
|
struct Response: Decodable {
|
|
struct Result: Decodable {
|
|
struct Pane: Decodable {
|
|
let paneID: String
|
|
let foregroundCwd: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case paneID = "pane_id"
|
|
case foregroundCwd = "foreground_cwd"
|
|
}
|
|
}
|
|
let panes: [Pane]
|
|
}
|
|
let result: Result
|
|
}
|
|
let response = try JSONDecoder().decode(Response.self, from: Data(native.utf8))
|
|
// Herdr clients can have independent tab locations. Global focus is not client focus.
|
|
guard response.result.panes.count == 1, let selected = response.result.panes.first,
|
|
RemoteHerdrDialect().validPane(selected.paneID)
|
|
else { return unavailable(.paneAmbiguous) }
|
|
pane = selected.paneID
|
|
directory = selected.foregroundCwd ?? ""
|
|
source = .process
|
|
default: throw TerminalRuntimeError.unsupported
|
|
}
|
|
guard RemoteMultiplexerCommands.validPath(directory) else { return unavailable(.directoryUnavailable) }
|
|
let identity = TerminalContextIdentity(
|
|
backend: client.backend.rawValue, server: endpoint + ":" + fingerprint, session: endpoint,
|
|
pane: pane, shellInstance: "native:" + (focus.isEmpty ? pane : focus))
|
|
let context = TerminalShellContext(
|
|
identity: identity, shell: "", directory: directory, home: nil, promptReady: false, sequence: 0,
|
|
capabilities: [], directorySource: source)
|
|
return .init(
|
|
status: .multiplexer, clientIdentity: client.identity, active: identity, contexts: [context],
|
|
backend: client.backend, issue: .shellIntegrationUnavailable)
|
|
}
|
|
|
|
static func zellijPane(_ text: String) -> String? {
|
|
let lines = text.split(separator: "\n").map { $0.split(whereSeparator: \.isWhitespace).map(String.init) }
|
|
guard lines.first == ["CLIENT_ID", "ZELLIJ_PANE_ID", "RUNNING_COMMAND"], lines.count > 1 else { return nil }
|
|
var panes: Set<String> = []
|
|
var clients: Set<String> = []
|
|
for fields in lines.dropFirst() {
|
|
guard fields.count >= 2, UInt64(fields[0]) != nil, clients.insert(fields[0]).inserted,
|
|
fields[1].hasPrefix("terminal_")
|
|
else { return nil }
|
|
let pane = String(fields[1].dropFirst("terminal_".count))
|
|
guard RemoteZellijDialect().validPane(pane) else { return nil }
|
|
panes.insert(pane)
|
|
}
|
|
// Without a native TTY-to-client ID API, only a unanimous focus is safe for display.
|
|
return panes.count == 1 ? panes.first : nil
|
|
}
|
|
|
|
static func isNested(_ command: String?) -> Bool {
|
|
guard let command else { return false }
|
|
let executable = (command.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? "") as NSString
|
|
return SessionPersistenceBackendID.supported.contains(.init(rawValue: executable.lastPathComponent))
|
|
|| ["ssh", "mosh", "et"].contains(executable.lastPathComponent)
|
|
}
|
|
}
|