164 lines
7.5 KiB
Swift
164 lines
7.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
nonisolated enum RemoteZellijCommands {
|
|
static let userRoot = #"""
|
|
if [ -n "${ZELLIJ_SOCKET_DIR:-}" ]; then root=$ZELLIJ_SOCKET_DIR
|
|
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zellij"
|
|
else root="${TMPDIR:-/tmp}/zellij-$(id -u)"; fi
|
|
"""#
|
|
|
|
static var list: String {
|
|
"""
|
|
\(RemoteMultiplexerCommands.discovery(.zellij))
|
|
\(RemoteMultiplexerCommands.utilities)
|
|
\(userRoot)
|
|
user_root=$root
|
|
for scope in user managed; do
|
|
if [ "$scope" = user ]; then root=$user_root; else root="/tmp/termind-zellij-$(id -u)"; fi
|
|
printf 'ROOT|%s|%s\\n' "$scope" "$(hex "$root")"
|
|
if [ -d "$root" ]; then
|
|
export ZELLIJ_SOCKET_DIR=$root
|
|
listing=$("$exe" list-sessions --short --no-formatting 2>&1)
|
|
code=$?
|
|
if [ "$code" = 0 ]; then
|
|
printf '%s\\n' "$listing" | while IFS= read -r name; do
|
|
case "$name" in ''|*[!a-zA-Z0-9_.-]*|.|..) exit 78 ;; esac
|
|
# list-sessions also includes serialized, exited sessions. Never offer those as live.
|
|
socket=$(find "$root" -type s -name "$name" -print 2>/dev/null) || exit 78
|
|
[ -S "$socket" ] && [ ! -L "$socket" ] || continue
|
|
"$exe" --session "$name" action list-panes --json >/dev/null 2>&1 || continue
|
|
printf '%s\\n' "$name"
|
|
done || exit 78
|
|
elif [ "$listing" != 'No active zellij sessions found.' ]; then printf '%s\\n' "$listing" >&2; exit 78; fi
|
|
fi
|
|
printf 'END\\n'
|
|
done
|
|
"""
|
|
}
|
|
|
|
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
|
guard text.hasSuffix("\n") else { throw RemoteSessionFailure.invalidResponse }
|
|
var result: [RemoteMultiplexerCandidate] = []
|
|
var root: String?
|
|
var isolated = false
|
|
var scopes: Set<String> = []
|
|
for line in text.split(separator: "\n").map(String.init) {
|
|
if line.hasPrefix("ROOT|") {
|
|
let fields = line.components(separatedBy: "|")
|
|
guard root == nil, fields.count == 3, ["user", "managed"].contains(fields[1]),
|
|
scopes.insert(fields[1]).inserted
|
|
else { throw RemoteSessionFailure.invalidResponse }
|
|
root = try RemoteMultiplexerCommands.unhex(fields[2])
|
|
isolated = fields[1] == "managed"
|
|
} else if line == "END" {
|
|
guard root != nil else { throw RemoteSessionFailure.invalidResponse }
|
|
root = nil
|
|
} else {
|
|
guard let root, RemoteMultiplexerCommands.validName(line) else {
|
|
throw RemoteSessionFailure.invalidResponse
|
|
}
|
|
result.append(.init(name: line, root: root, socket: nil, isolated: isolated))
|
|
}
|
|
}
|
|
guard root == nil, scopes == ["user", "managed"] else { throw RemoteSessionFailure.invalidResponse }
|
|
return result
|
|
}
|
|
|
|
static func create(name: String) -> String {
|
|
"""
|
|
root="/tmp/termind-zellij-$(id -u)"
|
|
private_dir "$root" || exit 78
|
|
export ZELLIJ_SOCKET_DIR=$root
|
|
export TERMIND_SHELL_CACHE_HOME_SET="${XDG_CACHE_HOME+x}" TERMIND_SHELL_CACHE_HOME="${XDG_CACHE_HOME-}"
|
|
private_dir "$root/cache" || exit 78
|
|
export XDG_CACHE_HOME="$root/cache"
|
|
existing=$(find "$root" -type s -name '\(name)' -print) || exit 78
|
|
[ -z "$existing" ] || exit 79
|
|
native_started=1
|
|
# Background creation ignores attach's CLI options on some Zellij releases.
|
|
# Layout configuration reaches the server and also covers panes created later.
|
|
"$exe" --layout-string "default_shell \\"$helper/shell\\"
|
|
session_serialization false
|
|
layout { pane; }" attach --create-background '\(name)' >/dev/null || exit 78
|
|
socket=$(find "$root" -type s -name '\(name)' -print) || exit 78
|
|
"""
|
|
}
|
|
}
|
|
|
|
nonisolated struct RemoteZellijDialect: RemoteSocketMultiplexerDialect {
|
|
let id = SessionPersistenceBackendID.zellij
|
|
let paneVariable: String? = "ZELLIJ_PANE_ID"
|
|
var list: String { RemoteZellijCommands.list }
|
|
|
|
func validateVersion(_ output: String) throws {
|
|
let version = try RemoteMultiplexerVersion(output, backend: id)
|
|
guard version.major > 0 || version.minor > 44 || (version.minor == 44 && version.patch >= 3) else {
|
|
throw RemoteSessionFailure.unsupported
|
|
}
|
|
}
|
|
func validPane(_ value: String) -> Bool {
|
|
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
|
|
}
|
|
func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
|
try RemoteZellijCommands.parseList(text)
|
|
}
|
|
func create(name: String, token: UUID) -> String { RemoteZellijCommands.create(name: name) }
|
|
func attach(name: String) -> String { "\"$exe\" attach \(name)" }
|
|
func stop(name: String) -> String { "\"$exe\" kill-session \(name)" }
|
|
func validateIdentity(_ identity: RemoteMultiplexerIdentity) throws {}
|
|
func validRoute(_ identity: RemoteMultiplexerIdentity, name: String) -> Bool {
|
|
identity.socket.hasSuffix("/" + name)
|
|
}
|
|
func routing(_ identity: RemoteMultiplexerIdentity) throws -> String {
|
|
"""
|
|
export ZELLIJ_SOCKET_DIR=\(try RemoteMultiplexerCommands.quote(identity.root))
|
|
\(identity.isolated ? "export XDG_CACHE_HOME=\(try RemoteMultiplexerCommands.quote(identity.root + "/cache"))" : "")
|
|
"""
|
|
}
|
|
func prepareAttachment() -> String {
|
|
"""
|
|
# An attach command can otherwise resurrect a cached session after a disconnect race.
|
|
cache="/tmp/termind-zellij-attach-cache-$(id -u)"
|
|
private_dir "$cache" || exit 79
|
|
export XDG_CACHE_HOME="$cache"
|
|
"""
|
|
}
|
|
var restoreShellEnvironment: String {
|
|
"""
|
|
if [ "${TERMIND_SHELL_CACHE_HOME_SET:-}" = x ]; then
|
|
export XDG_CACHE_HOME="$TERMIND_SHELL_CACHE_HOME"
|
|
else
|
|
unset XDG_CACHE_HOME
|
|
fi
|
|
unset TERMIND_SHELL_CACHE_HOME_SET TERMIND_SHELL_CACHE_HOME
|
|
"""
|
|
}
|
|
func runtimeCommands(name: String) -> (String, String) {
|
|
("\"$exe\" list-sessions --short --no-formatting", "\"$exe\" --session \(name) action list-clients")
|
|
}
|
|
func activePane(
|
|
native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool = false
|
|
) throws -> String? {
|
|
// A switched client cannot be bound using its original argv. Require a unique live session too.
|
|
guard
|
|
clientBound
|
|
? native.split(separator: "\n").contains(Substring(reference.session.name))
|
|
: native == reference.session.name
|
|
else { return nil }
|
|
let clients = focus.split(separator: "\n")
|
|
guard clients.first == "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND", clients.count == 2 else { return nil }
|
|
let fields = clients[1].split(whereSeparator: \.isWhitespace)
|
|
guard fields.count >= 3, UInt64(fields[0]) != nil, fields[1].hasPrefix("terminal_") else { return nil }
|
|
let pane = String(fields[1].dropFirst("terminal_".count))
|
|
return validPane(pane) ? pane : nil
|
|
}
|
|
func writeDraft(name: String, pane: String, key: String) -> String {
|
|
"\"$exe\" --session \(name) action write-chars --pane-id \(pane) \(key)"
|
|
}
|
|
}
|