Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift
T

163 lines
8.1 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindDomain
/// Private, per-session startup files. Nothing is ever typed into an attached terminal.
nonisolated enum RemoteShellStartup {
static func ordinaryCommand(helper: String) throws -> String {
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\""
}
static func install(
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false,
backend: String? = nil, environment: [HostEnvironmentVariable] = []
) throws -> String {
let eventToken = ordinary ? token : UUID()
let bootstrap = RemoteShellIntegrationBootstrap(token: eventToken.uuidString)
let startup = try RemoteMultiplexerCommands.quote(startupCommand ?? "")
let reportsStartup = shellIntegration && startupCommand?.isEmpty == false
let posixStartup = """
if [ "${TERMIND_TMUX_FIRST:-}" = 1 ]; then
unset TERMIND_TMUX_FIRST
\(backend == nil ? "" : "mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null || return")
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
\(reportsStartup ? "__termind_startup_time=$(date +%s); __termind_event start \"$(__termind_encode \(startup))\"" : "")
eval \(startup)
\(reportsStartup ? "__termind_startup_status=$?; __termind_event finish \"$__termind_startup_status\" \"$(( ($(date +%s) - __termind_startup_time) * 1000 ))\"" : "")
fi
"""
let fishStartup = """
if test "$TERMIND_TMUX_FIRST" = 1
set -e TERMIND_TMUX_FIRST
\(backend == nil ? "" : "command mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null; or return")
printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started"
\(reportsStartup ? "set -g __termind_startup_time (date +%s); __termind_event start (string escape --style=url -- \(startup))" : "")
eval \(startup)
\(reportsStartup ? "set -l command_status $status; set -l elapsed (math (date +%s) - $__termind_startup_time); __termind_event finish $command_status (math $elapsed '*' 1000)" : "")
end
"""
let bash =
(shellIntegration
? RemoteShellContextJournal.initialization(
shell: "bash", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "bash", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + (shellIntegration ? "__termind_command_ready=0\n" : "") + posixStartup
+ (shellIntegration ? "\n__termind_report_prompt\n" : "")
let zsh =
(shellIntegration
? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "zsh", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + posixStartup
let fish =
(shellIntegration
? RemoteShellContextJournal.initialization(
shell: "fish", token: eventToken.uuidString, backend: backend)
+ "\n" + String(decoding: bootstrap.installationCommand(for: "fish", runtime: true)!, as: UTF8.self)
: "")
+ "\n" + fishStartup
let shell = """
#!/bin/sh
if [ "${1:-}" = --termind-check-executable ]; then exit 0; fi
\(backend == nil ? "" : """
if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then
export LC_ALL="$TERMIND_SHELL_LOCALE"
else
unset LC_ALL
fi
unset TERMIND_SHELL_LOCALE_SET TERMIND_SHELL_LOCALE
""")
\(backend.flatMap { RemoteMultiplexerRegistry.socketDialect(.init(rawValue: $0)) }?.restoreShellEnvironment ?? "")
\(try RemoteMultiplexerCommands.environment(environment))
SHELL=${TERMIND_TMUX_SHELL:-${SHELL:-/bin/sh}}
export SHELL
if [ "$#" -gt 0 ]; then exec "$SHELL" "$@"; fi
unset __termind_context_dir
\(backend == nil ? "" : """
unset TERMIND_TMUX_FIRST
if mkdir "$TERMIND_TMUX_HELPERS/first-claimed" 2>/dev/null; then
export TERMIND_TMUX_FIRST=1
fi
""")
export TERMIND_CONTEXT_OSC=\(ordinary ? "1" : "0")
case "$SHELL" in
*/bash)
export PROMPT_COMMAND='. "$TERMIND_TMUX_HELPERS/bashrc"'
exec "$SHELL" --noprofile -il ;;
*/zsh) export TERMIND_USER_ZDOTDIR="${ZDOTDIR:-$HOME}"; export ZDOTDIR="$TERMIND_TMUX_HELPERS"; exec "$SHELL" -il ;;
*/fish) exec "$SHELL" -il --init-command "source \\"$TERMIND_TMUX_HELPERS/fish\\"" ;;
*) exec "${SHELL:-/bin/sh}" -l ;;
esac
"""
let bashrc = """
[ -z "${__termind_context_dir:-}" ] || return
unset PROMPT_COMMAND
# Keep login-shell semantics while running the hooks after user configuration.
# An exec in a login script intentionally bypasses the rest of this wrapper.
[ ! -r /etc/profile ] || . /etc/profile
for profile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
if [ -r "$profile" ]; then . "$profile"; break; fi
done
\(bash)
"""
// Run the first command inside the initialized shell, preserving cd/export state.
// Subsequent ordinary panes use `shell` directly and cannot replay this command.
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"\n"
var files = [
"shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer,
"request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString,
]
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
files[name] = """
export ZDOTDIR="$TERMIND_USER_ZDOTDIR"
[[ -r "$TERMIND_USER_ZDOTDIR/\(name)" ]] && source "$TERMIND_USER_ZDOTDIR/\(name)"
export TERMIND_USER_ZDOTDIR="$ZDOTDIR"
export ZDOTDIR="$TERMIND_TMUX_HELPERS"
"""
}
files[".zlogin", default: ""] += "\nexport ZDOTDIR=\"$TERMIND_USER_ZDOTDIR\"\n\(zsh)\n"
let writes = try files.keys.sorted().map { name in
"printf '%s\\n' \(try RemoteMultiplexerCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78"
}.joined(separator: "\n")
return """
umask 077
helper=$(mktemp -d "/tmp/termind-shell.XXXXXXXX") || exit 78
\(writes)
chmod 700 "$helper/shell" "$helper/first" || exit 78
\(ordinary ? "" : """
"$helper/shell" --termind-check-executable >/dev/null 2>&1 || { rm -rf "$helper"; exit 78; }
""")
"""
}
}
/// Only ordinary startup uses the plain-shell fallback, never multiplexer inspection.
nonisolated struct RemoteShellStartupReadiness {
private let generation: UUID
private var deadline: UInt64?
init(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) {
self.generation = generation
deadline = now + 3_000_000_000
}
mutating func receive(_ update: TerminalRuntimeUpdate) {
if update.status == .ordinary || update.status == .multiplexer || update.backend != nil {
deadline = nil
}
}
mutating func poll(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) -> Bool {
guard self.generation == generation else {
deadline = nil
return false
}
guard let deadline, now >= deadline else { return false }
self.deadline = nil
return true
}
}