164 lines
7.6 KiB
Swift
164 lines
7.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
nonisolated enum RemoteHerdrCommands {
|
|
private struct Listing: Decodable {
|
|
let sessions: [Session]
|
|
struct Session: Decodable {
|
|
let name: String
|
|
let running: Bool
|
|
let socketPath: String
|
|
let sessionDir: String
|
|
let connectionError: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, running
|
|
case socketPath = "socket_path"
|
|
case sessionDir = "session_dir"
|
|
case connectionError = "connection_error"
|
|
}
|
|
}
|
|
}
|
|
|
|
static var list: String {
|
|
RemoteMultiplexerCommands.discovery(.herdr) + "\n\"$exe\" session list --json"
|
|
}
|
|
|
|
static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] {
|
|
let value: Listing
|
|
do { value = try JSONDecoder().decode(Listing.self, from: Data(text.utf8)) } catch {
|
|
throw RemoteSessionFailure.invalidResponse
|
|
}
|
|
return try value.sessions.compactMap { session in
|
|
guard session.connectionError == nil else { throw RemoteSessionFailure.invalidResponse }
|
|
guard RemoteMultiplexerCommands.validName(session.name),
|
|
RemoteMultiplexerCommands.validPath(session.sessionDir),
|
|
session.socketPath == session.sessionDir + "/herdr.sock"
|
|
else { throw RemoteSessionFailure.invalidResponse }
|
|
guard session.running else { return nil }
|
|
return .init(name: session.name, root: session.sessionDir, socket: session.socketPath)
|
|
}
|
|
}
|
|
|
|
static func create(name: String) -> String {
|
|
"""
|
|
root="${XDG_CONFIG_HOME:-$HOME/.config}/herdr/sessions"
|
|
mkdir -p "$root" || exit 78
|
|
[ ! -L "$root" ] || exit 79
|
|
mkdir "$root/\(name)" || exit 79
|
|
chmod 700 "$root/\(name)" || exit 78
|
|
socket="$root/\(name)/herdr.sock"
|
|
# Only the new named server inherits the wrapper; the user's config remains untouched.
|
|
native_started=1
|
|
SHELL="$helper/shell" HERDR_STARTUP_CWD="$PWD" nohup "$exe" --session '\(name)' server \
|
|
</dev/null >"$meta/create-error" 2>&1 &
|
|
attempt=0
|
|
while [ ! -S "$socket" ]; do
|
|
attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 78
|
|
sleep 0.1
|
|
done
|
|
"""
|
|
}
|
|
}
|
|
|
|
nonisolated struct RemoteHerdrDialect: RemoteSocketMultiplexerDialect {
|
|
let id = SessionPersistenceBackendID.herdr
|
|
let paneVariable: String? = "HERDR_PANE_ID"
|
|
var list: String { RemoteHerdrCommands.list }
|
|
|
|
func validateVersion(_ output: String) throws {
|
|
let version = try RemoteMultiplexerVersion(output, backend: id)
|
|
// Preserve both supported release lines; 0.8.0 and 0.8.1 lack the lifecycle fixes.
|
|
guard
|
|
version.major > 0 || version.minor > 8 || (version.minor == 8 && version.patch >= 2)
|
|
|| (version.minor == 7 && version.patch >= 5)
|
|
else { throw RemoteSessionFailure.unsupported }
|
|
}
|
|
func validPane(_ value: String) -> Bool {
|
|
!value.isEmpty && value.utf8.count <= 128
|
|
&& value.utf8.allSatisfy {
|
|
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
|
|
|| [45, 58, 95].contains($0)
|
|
}
|
|
}
|
|
func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] { try RemoteHerdrCommands.parseList(text) }
|
|
func create(name: String, token: UUID) -> String { RemoteHerdrCommands.create(name: name) }
|
|
func attach(name: String) -> String { "\"$exe\" --session \(name) client" }
|
|
func stop(name: String) -> String { "\"$exe\" session stop \(name) --json" }
|
|
func validRoute(_ identity: RemoteMultiplexerIdentity, name: String) -> Bool {
|
|
identity.socket == identity.root + "/herdr.sock"
|
|
&& (name == "default"
|
|
? identity.root.hasSuffix("/herdr") : identity.root.hasSuffix("/herdr/sessions/" + name))
|
|
}
|
|
func routing(_ identity: RemoteMultiplexerIdentity) throws -> String {
|
|
let root = identity.root
|
|
let config: String
|
|
if root.hasSuffix("/herdr") {
|
|
config = String(root.dropLast("/herdr".count))
|
|
} else if let range = root.range(of: "/herdr/sessions/", options: .backwards),
|
|
RemoteMultiplexerCommands.validName(String(root[range.upperBound...]))
|
|
{
|
|
config = String(root[..<range.lowerBound])
|
|
} else {
|
|
throw RemoteSessionFailure.invalidResponse
|
|
}
|
|
guard RemoteMultiplexerCommands.validPath(config) else { throw RemoteSessionFailure.invalidResponse }
|
|
return "export XDG_CONFIG_HOME=\(try RemoteMultiplexerCommands.quote(config))"
|
|
}
|
|
func metadataFields(_ candidate: RemoteMultiplexerCandidate) throws -> String {
|
|
"directory=$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(candidate.root))) || exit 79"
|
|
}
|
|
func validateTermination(_ reference: RemoteSessionReference, identity: RemoteMultiplexerIdentity) throws {
|
|
guard reference.session.name != "default", identity.directoryFingerprint != nil else {
|
|
throw RemoteSessionFailure.ownershipMismatch
|
|
}
|
|
}
|
|
func terminationCleanup(_ reference: RemoteSessionReference, identity: RemoteMultiplexerIdentity) throws -> String {
|
|
"""
|
|
private_state || exit 79
|
|
[ "$(cat "$meta/owner")" = '\(reference.ownershipToken!.uuidString)' ] || exit 79
|
|
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
|
|
[ "$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(identity.root)))" = '\(identity.directoryFingerprint!)' ] || exit 79
|
|
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.socket)) ] || exit 79
|
|
"$exe" session delete \(try RemoteMultiplexerCommands.quote(reference.session.name)) --json >/dev/null || exit 78
|
|
[ ! -e \(try RemoteMultiplexerCommands.quote(identity.root)) ] || exit 79
|
|
"""
|
|
}
|
|
func runtimeCommands(name: String) -> (String, String) {
|
|
("\"$exe\" session list --json", "\"$exe\" --session \(name) pane list")
|
|
}
|
|
func activePane(
|
|
native: String, focus: String, reference: RemoteSessionReference, clientBound: Bool = false
|
|
) throws -> String? {
|
|
let sessions = try RemoteHerdrCommands.parseList(native)
|
|
guard clientBound || sessions.count == 1, sessions.contains(where: { $0.name == reference.session.name }) else {
|
|
return nil
|
|
}
|
|
struct Response: Decodable {
|
|
struct Result: Decodable {
|
|
struct Pane: Decodable {
|
|
let paneID: String
|
|
enum CodingKeys: String, CodingKey { case paneID = "pane_id" }
|
|
}
|
|
let panes: [Pane]
|
|
}
|
|
let result: Result
|
|
}
|
|
let response = try JSONDecoder().decode(Response.self, from: Data(focus.utf8))
|
|
// Herdr's pane-current is global, not a per-client focus API.
|
|
guard response.result.panes.count == 1, let pane = response.result.panes.first?.paneID, validPane(pane) else {
|
|
return nil
|
|
}
|
|
// A verified endpoint with exactly one pane has no per-client pane ambiguity.
|
|
// Without that endpoint evidence, even a singleton might belong to a switched-away session.
|
|
return clientBound ? pane : nil
|
|
}
|
|
func writeDraft(name: String, pane: String, key: String) -> String {
|
|
"\"$exe\" --session \(name) pane send-text \(pane) \(key)"
|
|
}
|
|
}
|