mirror of
https://github.com/lihop/godot-xterm.git
synced 2025-05-04 20:24:23 +02:00
Add new PTY node (replaces Pseudoterminal node)
Uses fork of node-pty native code for forking pseudoterminals. Uses libuv pipe handle to communicate with the child process. - Paves the way for cross-platform (Linux, macOS and Windows) support. - Renames Pseudoterminal to PTY (which is much easier to type and spell :D). - Better performance than the old Pseudoterminal node. Especially when streaming large amounts of data such as running the `yes` command. - Allows setting custom file, args, initial window size, cwd, env vars (including important ones such as TERM and COLORTERM) and uid/gid on Linux and macOS. - Returns process exit code and terminating signal.
This commit is contained in:
parent
bfa561357e
commit
0dd2378387
36 changed files with 1268 additions and 442 deletions
29
addons/godot_xterm/nodes/pty/libuv_utils.gd
Normal file
29
addons/godot_xterm/nodes/pty/libuv_utils.gd
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Copyright (c) 2021, Leroy Hopson (MIT License)
|
||||
|
||||
tool
|
||||
extends Object
|
||||
# Wrapper around libuv utility functions.
|
||||
# GDNative does not currently support registering static functions so we fake it.
|
||||
# Only the static functions of this class should be called.
|
||||
|
||||
const LibuvUtils := preload("./libuv_utils.gdns")
|
||||
|
||||
static func get_os_environ() -> Dictionary:
|
||||
# While Godot has OS.get_environment(), I could see a way to get all environent
|
||||
# variables, other than by OS.execute() which would require to much platform
|
||||
# specific code. Easier to use libuv's utility function.
|
||||
return LibuvUtils.new().get_os_environ()
|
||||
|
||||
static func get_cwd() -> String:
|
||||
# Use uv_cwd() rather than Directory.get_current_dir() because the latter
|
||||
# defaults to res:// even if starting godot from a different directory.
|
||||
return LibuvUtils.new().get_cwd()
|
||||
|
||||
static func get_windows_build_number() -> int:
|
||||
assert(OS.get_name() == "Windows", "This function is only supported on Windows.")
|
||||
var release: String = LibuvUtils.new().get_os_release()
|
||||
assert(false, "Not implemented.")
|
||||
return 0
|
||||
|
||||
static func new():
|
||||
assert(false, "Abstract sealed (i.e. static) class should not be instantiated.")
|
8
addons/godot_xterm/nodes/pty/libuv_utils.gdns
Normal file
8
addons/godot_xterm/nodes/pty/libuv_utils.gdns
Normal file
|
@ -0,0 +1,8 @@
|
|||
[gd_resource type="NativeScript" load_steps=2 format=2]
|
||||
|
||||
[ext_resource path="res://addons/godot_xterm/native/godotxtermnative.gdnlib" type="GDNativeLibrary" id=1]
|
||||
|
||||
[resource]
|
||||
resource_name = "Terminal"
|
||||
class_name = "LibuvUtils"
|
||||
library = ExtResource( 1 )
|
8
addons/godot_xterm/nodes/pty/pipe.gdns
Normal file
8
addons/godot_xterm/nodes/pty/pipe.gdns
Normal file
|
@ -0,0 +1,8 @@
|
|||
[gd_resource type="NativeScript" load_steps=2 format=2]
|
||||
|
||||
[ext_resource path="res://addons/godot_xterm/native/godotxtermnative.gdnlib" type="GDNativeLibrary" id=1]
|
||||
|
||||
[resource]
|
||||
resource_name = "Terminal"
|
||||
class_name = "Pipe"
|
||||
library = ExtResource( 1 )
|
261
addons/godot_xterm/nodes/pty/pty.gd
Normal file
261
addons/godot_xterm/nodes/pty/pty.gd
Normal file
|
@ -0,0 +1,261 @@
|
|||
# Derived from https://github.com/microsoft/node-pty/blob/main/src/terminal.ts
|
||||
# Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
|
||||
# Copyright (c) 2016, Daniel Imms (MIT License).
|
||||
# Copyright (c) 2018, Microsoft Corporation (MIT License).
|
||||
# Copyright (c) 2021, Leroy Hopson (MIT License).
|
||||
|
||||
tool
|
||||
extends Node
|
||||
|
||||
const LibuvUtils := preload("./libuv_utils.gd")
|
||||
const Pipe := preload("./pipe.gdns")
|
||||
const Terminal := preload("../terminal/terminal.gd")
|
||||
|
||||
const DEFAULT_NAME := "xterm-256color"
|
||||
const DEFAULT_COLS := 80
|
||||
const DEFAULT_ROWS := 24
|
||||
const DEFAULT_ENV := {TERM = DEFAULT_NAME, COLORTERM = "truecolor"}
|
||||
|
||||
## Default messages to indicate PAUSE/RESUME for automatic flow control.
|
||||
## To avoid conflicts with rebound XON/XOFF control codes (such as on-my-zsh),
|
||||
## the sequences can be customized in IPtyForkOptions.
|
||||
#const FLOW_CONTROL_PAUSE = char(0x13) # defaults to XOFF
|
||||
#const FLOW_CONTROL_RESUME = char(0x11) # defaults to XON
|
||||
|
||||
enum Status { NONE, OPEN, EXITED, ERROR }
|
||||
const STATUS_NONE = Status.NONE
|
||||
const STATUS_OPEN = Status.OPEN
|
||||
const STATUS_EXITED = Status.EXITED
|
||||
const STATUS_ERROR = Status.ERROR
|
||||
|
||||
# Any signal_number can be sent to the pty's process using the kill() function,
|
||||
# these are just the signals with numbers specified in the POSIX standard.
|
||||
enum Signal {
|
||||
SIGHUP = 1, # Hangup
|
||||
SIGINT = 2, # Terminal interrupt signal
|
||||
SIGQUIT = 3, # Terminal quit signal
|
||||
SIGILL = 4, # Illegal instruction
|
||||
SIGTRAP = 5, # Trace/breakpoint trap
|
||||
SIGABRT = 6, # Process abort signal
|
||||
SIGFPE = 8, # Erroneous arithmetic operation
|
||||
SIGKILL = 9, # Kill (cannot be caught or ignored)
|
||||
SIGSEGV = 11, # Invalid memory reference
|
||||
SIGPIPE = 13, # Write on a pipe with no one to read it
|
||||
SIGALRM = 14, # Alarm clock
|
||||
SIGTERM = 15, # Termination signal
|
||||
}
|
||||
|
||||
signal data_received(data)
|
||||
signal exited(exit_code, signum)
|
||||
signal errored(message)
|
||||
|
||||
export (NodePath) var terminal_path := NodePath() setget set_terminal_path
|
||||
|
||||
var status := STATUS_NONE
|
||||
var error_string := ""
|
||||
var terminal: Terminal = null setget set_terminal
|
||||
|
||||
## Name of the terminal to be set in environment ($TERM variable).
|
||||
#export (String) var term_name: String
|
||||
|
||||
# The name of the process.
|
||||
var process: String
|
||||
|
||||
# The process ID.
|
||||
var pid: int
|
||||
|
||||
# The column size in characters.
|
||||
export (int) var cols: int = DEFAULT_COLS setget set_cols
|
||||
|
||||
# The row size in characters.
|
||||
export (int) var rows: int = DEFAULT_ROWS setget set_rows
|
||||
|
||||
# Working directory to be set for the child program.
|
||||
#export (String) var cwd := LibuvUtils.get_cwd()
|
||||
|
||||
# Environment to be set for the child program.
|
||||
export (Dictionary) var env := DEFAULT_ENV
|
||||
|
||||
# If true the environment variables in the env Dictionary will be merged with
|
||||
# the environment variables of the operating system (e.g. printenv), with the
|
||||
# former taking precedence in the case of conflicts.
|
||||
export (bool) var use_os_env := true
|
||||
|
||||
# If true, pty will call fork() in in _ready().
|
||||
export (bool) var autostart := false
|
||||
|
||||
# (EXPERIMENTAL)
|
||||
# If true, PTY node will create a blocking libuv loop in a new thread.
|
||||
# signals will be emitted using call_deferred.
|
||||
#export (bool) var use_threads := false
|
||||
|
||||
## String encoding of the underlying pty.
|
||||
## If set, incoming data will be decoded to strings and outgoing strings to bytes applying this encoding.
|
||||
## If unset, incoming data will be delivered as raw bytes (PoolByteArray type).
|
||||
## By default 'utf8' is assumed, to unset it explicitly set it to `null`.
|
||||
#var encoding: String = "utf8"
|
||||
|
||||
## (EXPERIMENTAL)
|
||||
## Whether to enable flow control handling (false by default). If enabled a message of `flow_control_pause`
|
||||
## will pause the socket and thus blocking the child program execution due to buffer back pressure.
|
||||
## A message of `flow_control_resume` will resume the socket into flow mode.
|
||||
## For performance reasons only a single message as a whole will match (no message part matching).
|
||||
## If flow control is enabled the `flow_control_pause` and `flow_control_resume` messages are not forwarded to
|
||||
## the underlying pseudoterminal.
|
||||
#var handle_flow_control: bool = false
|
||||
#
|
||||
## (EXPERIMENTAL)
|
||||
## The string that should pause the pty when `handle_flow_control` is true. Default is XOFF ("\u0013").
|
||||
#var flow_control_pause: String = FLOW_CONTROL_PAUSE
|
||||
#
|
||||
## (EXPERIMENTAL)
|
||||
## The string that should resume the pty when `handle_flow_control` is true. Default is XON ("\u0011").
|
||||
#var flow_control_resume: String = FLOW_CONTROL_RESUME
|
||||
|
||||
#var _native_term # Platform appropriate instance of this class.
|
||||
var _pipe: Pipe
|
||||
|
||||
|
||||
func set_cols(value: int):
|
||||
resize(value, rows)
|
||||
|
||||
|
||||
func set_rows(value: int):
|
||||
resize(cols, value)
|
||||
|
||||
|
||||
func set_terminal_path(value := NodePath()):
|
||||
terminal_path = value
|
||||
set_terminal(get_node_or_null(terminal_path))
|
||||
|
||||
|
||||
func set_terminal(value: Terminal):
|
||||
if terminal == value:
|
||||
return
|
||||
|
||||
# Disconect the current terminal, if any.
|
||||
if terminal:
|
||||
disconnect("data_received", terminal, "write")
|
||||
terminal.disconnect("data_sent", self, "write")
|
||||
terminal.disconnect("size_changed", self, "resize")
|
||||
|
||||
terminal = value
|
||||
|
||||
if not terminal:
|
||||
return
|
||||
|
||||
# Connect the new terminal.
|
||||
# FIXME! resize(terminal.get_cols(), terminal.get_rows())
|
||||
if not terminal.is_connected("size_changed", self, "resize"):
|
||||
terminal.connect("size_changed", self, "resize")
|
||||
if not terminal.is_connected("data_sent", self, "write"):
|
||||
terminal.connect("data_sent", self, "write")
|
||||
if not is_connected("data_received", terminal, "write"):
|
||||
connect("data_received", terminal, "write")
|
||||
|
||||
|
||||
# Writes data to the socket.
|
||||
# data: The data to write.
|
||||
func write(data) -> void:
|
||||
assert(data is String or data is PoolByteArray)
|
||||
|
||||
if data is PoolByteArray:
|
||||
data = data.get_string_from_utf8()
|
||||
|
||||
# if handle_flow_control:
|
||||
# # PAUSE/RESUME messages are not forwarded to the pty.
|
||||
# if data == flow_control_pause:
|
||||
# pause()
|
||||
# return
|
||||
# if data == flow_control_resume:
|
||||
# resume()
|
||||
# return
|
||||
# # Everything else goes to the real pty.
|
||||
_write(data)
|
||||
|
||||
|
||||
func _write(data: String) -> void:
|
||||
if _pipe:
|
||||
_pipe.write(data)
|
||||
|
||||
|
||||
# Resizes the dimensions of the pty.
|
||||
# cols: The number of columns.
|
||||
# rows: The number of rows.
|
||||
# Also accepts a single Vector2 argument where x is the the number of columns
|
||||
# and y is the number of rows.
|
||||
func resize(cols, rows = null) -> void:
|
||||
assert(
|
||||
(cols is Vector2 and rows == null) or (cols is int and rows is int),
|
||||
"Usage: resize(size: Vector2) or resize(cols: int, rows: int)"
|
||||
)
|
||||
|
||||
if cols is Vector2:
|
||||
rows = cols.y # Must get rows before reassigning cols!
|
||||
cols = cols.x
|
||||
|
||||
if cols <= 0 or rows <= 0 or cols == NAN or rows == NAN or cols == INF or rows == INF:
|
||||
push_error("Resizing must be done using positive cols and rows.")
|
||||
|
||||
_resize(cols, rows)
|
||||
|
||||
|
||||
func _resize(cols: int, rows: int) -> void:
|
||||
assert(false, "Not implemented.")
|
||||
|
||||
|
||||
# Close, kill and destroy the pipe.
|
||||
func destroy() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# Kill the pty.
|
||||
# sigint: The signal to send. By default this is SIGHUP.
|
||||
# This is not supported on Windows.
|
||||
func kill(sigint: int = Signal.SIGHUP) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func fork(
|
||||
p_file: String,
|
||||
p_args = PoolStringArray(),
|
||||
p_cwd: String = LibuvUtils.get_cwd(),
|
||||
p_cols: int = DEFAULT_COLS,
|
||||
p_rows: int = DEFAULT_ROWS
|
||||
):
|
||||
push_error("Not implemented.")
|
||||
|
||||
|
||||
func open(cols: int = DEFAULT_COLS, rows: int = DEFAULT_ROWS) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _parse_env(env: Dictionary = {}) -> PoolStringArray:
|
||||
var keys := env.keys()
|
||||
var pairs := PoolStringArray()
|
||||
|
||||
for key in keys:
|
||||
var value = env[key]
|
||||
var valid = key is String and value is String
|
||||
assert(valid, "Env key/value pairs must be of type String/String.")
|
||||
|
||||
if not valid:
|
||||
push_warning("Skipping invalid env key/value pair.")
|
||||
continue
|
||||
|
||||
pairs.append("%s=%s" % [key, value])
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
func _process(_delta):
|
||||
if _pipe:
|
||||
_pipe.poll()
|
||||
|
||||
|
||||
func _notification(what: int):
|
||||
match what:
|
||||
NOTIFICATION_PARENTED:
|
||||
var parent = get_parent()
|
||||
if parent is Terminal:
|
||||
set_terminal_path(get_path_to(parent))
|
51
addons/godot_xterm/nodes/pty/pty_icon.svg
Normal file
51
addons/godot_xterm/nodes/pty/pty_icon.svg
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
id="svg4716"
|
||||
version="1.1"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
height="16">
|
||||
<metadata
|
||||
id="metadata4722">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs4720">
|
||||
<clipPath
|
||||
id="clipPath826"
|
||||
clipPathUnits="userSpaceOnUse">
|
||||
<use
|
||||
height="100%"
|
||||
width="100%"
|
||||
id="use828"
|
||||
xlink:href="#g822"
|
||||
y="0"
|
||||
x="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
clip-path="url(#clipPath826)"
|
||||
id="g824">
|
||||
<g
|
||||
id="g822">
|
||||
<path
|
||||
style="fill:#e0e0e0;fill-opacity:0.99607999"
|
||||
d="M 4,1 1,5 H 3 V 8 H 5 V 5 h 2 z m 7,0 V 4 H 9 l 3,4 3,-4 H 13 V 1 Z m -2,9 v 1 h 1 v 4 h 1 v -4 h 1 v -1 z m -4,0 v 2 1 2 H 6 V 13 H 7 8 V 12 10 H 6 Z m 1,1 h 1 v 1 H 6 Z"
|
||||
id="path4714" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
34
addons/godot_xterm/nodes/pty/pty_icon.svg.import
Normal file
34
addons/godot_xterm/nodes/pty/pty_icon.svg.import
Normal file
|
@ -0,0 +1,34 @@
|
|||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="StreamTexture"
|
||||
path="res://.import/pty_icon.svg-e9e42570b4744b3370a02d174395c793.stex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/godot_xterm/nodes/pty/pty_icon.svg"
|
||||
dest_files=[ "res://.import/pty_icon.svg-e9e42570b4744b3370a02d174395c793.stex" ]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_mode=0
|
||||
compress/bptc_ldr=0
|
||||
compress/normal_map=0
|
||||
flags/repeat=0
|
||||
flags/filter=true
|
||||
flags/mipmaps=false
|
||||
flags/anisotropic=false
|
||||
flags/srgb=2
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/HDR_as_SRGB=false
|
||||
process/invert_color=false
|
||||
stream=false
|
||||
size_limit=0
|
||||
detect_3d=true
|
||||
svg/scale=1.0
|
124
addons/godot_xterm/nodes/pty/unix/pty_unix.gd
Normal file
124
addons/godot_xterm/nodes/pty/unix/pty_unix.gd
Normal file
|
@ -0,0 +1,124 @@
|
|||
# Derived from https://github.com/microsoft/node-pty/blob/main/src/unixTerminal.ts
|
||||
# Copyright (c) 2012-2015, Christopher Jeffrey (MIT License).
|
||||
# Copyright (c) 2016, Daniel Imms (MIT License).
|
||||
# Copyright (c) 2018, Microsoft Corporation (MIT License).
|
||||
# Copyright (c) 2021, Leroy Hopson (MIT License).
|
||||
|
||||
tool
|
||||
extends "../pty.gd"
|
||||
|
||||
const PTYUnix = preload("./pty_unix.gdns")
|
||||
|
||||
const FALLBACK_FILE = "sh"
|
||||
|
||||
# Security warning: use this option with great caution, as opened file descriptors
|
||||
# with higher privileges might leak to the child program.
|
||||
var uid: int
|
||||
var gid: int
|
||||
|
||||
var thread: Thread
|
||||
|
||||
var _fd: int = -1
|
||||
var _exit_cb: FuncRef
|
||||
|
||||
static func get_uid() -> int:
|
||||
return -1 # Not implemented.
|
||||
|
||||
static func get_gid() -> int:
|
||||
return -1 # Not implemented.
|
||||
|
||||
|
||||
func _ready():
|
||||
if autostart:
|
||||
fork()
|
||||
|
||||
|
||||
func _resize(cols: int, rows: int) -> void:
|
||||
if _fd < 0:
|
||||
return
|
||||
|
||||
PTYUnix.new().resize(_fd, cols, rows)
|
||||
|
||||
|
||||
func _fork_thread(args):
|
||||
var result = preload("./pty_unix.gdns").new().callv("fork", args)
|
||||
print(result)
|
||||
return result
|
||||
|
||||
|
||||
func fork(
|
||||
file: String = OS.get_environment("SHELL"),
|
||||
args: PoolStringArray = PoolStringArray(),
|
||||
cwd = LibuvUtils.get_cwd(),
|
||||
p_cols: int = DEFAULT_COLS,
|
||||
p_rows: int = DEFAULT_ROWS,
|
||||
uid: int = -1,
|
||||
gid: int = -1,
|
||||
utf8 = true
|
||||
):
|
||||
# File.
|
||||
if file.empty():
|
||||
file = FALLBACK_FILE
|
||||
|
||||
# Environment variables.
|
||||
# If we are using OS env vars, sanitize them to remove variables that might confuse our terminal.
|
||||
var final_env := _sanitize_env(LibuvUtils.get_os_environ()) if use_os_env else {}
|
||||
for key in env.keys():
|
||||
final_env[key] = env[key]
|
||||
var parsed_env: PoolStringArray = _parse_env(final_env)
|
||||
|
||||
# Exit callback.
|
||||
_exit_cb = FuncRef.new()
|
||||
_exit_cb.set_instance(self)
|
||||
_exit_cb.function = "_on_exit"
|
||||
|
||||
# Actual fork.
|
||||
var result = PTYUnix.new().fork( # VERY IMPORTANT: The must be set null or 0, otherwise will get an ENOTSOCK error after connecting our pipe to the fd.
|
||||
file, null, args, parsed_env, cwd, cols, rows, uid, gid, utf8, _exit_cb
|
||||
)
|
||||
|
||||
if result[0] != OK:
|
||||
push_error("Fork failed.")
|
||||
status = STATUS_ERROR
|
||||
return FAILED
|
||||
|
||||
_fd = result[1].fd
|
||||
if _fd < 0:
|
||||
push_error("File descriptor must be a non-negative integer value.")
|
||||
status = STATUS_ERROR
|
||||
return FAILED
|
||||
|
||||
_pipe = Pipe.new()
|
||||
_pipe.open(_fd)
|
||||
|
||||
# Must connect to signal AFTER opening, otherwise we will get error ENOTSOCK.
|
||||
_pipe.connect("data_received", self, "_on_pipe_data_received")
|
||||
|
||||
return OK
|
||||
|
||||
|
||||
func _on_pipe_data_received(data):
|
||||
emit_signal("data_received", data)
|
||||
|
||||
|
||||
func _on_exit(exit_code: int, signum: int) -> void:
|
||||
emit_signal("exited", exit_code, signum)
|
||||
|
||||
|
||||
func _sanitize_env(env: Dictionary) -> Dictionary:
|
||||
# Make sure we didn't start our server from inside tmux.
|
||||
env.erase("TMUX")
|
||||
env.erase("TMUX_PANE")
|
||||
|
||||
# Make sure we didn't start our server from inside screen.
|
||||
# http://web.mit.edu/gnu/doc/html/screen_20.html
|
||||
env.erase("STY")
|
||||
env.erase("WINDOW")
|
||||
|
||||
# Delete some variables that might confuse our terminal.
|
||||
env.erase("WINDOWID")
|
||||
env.erase("TERMCAP")
|
||||
env.erase("COLUMNS")
|
||||
env.erase("LINES")
|
||||
|
||||
return env
|
7
addons/godot_xterm/nodes/pty/unix/pty_unix.gdns
Normal file
7
addons/godot_xterm/nodes/pty/unix/pty_unix.gdns
Normal file
|
@ -0,0 +1,7 @@
|
|||
[gd_resource type="NativeScript" load_steps=2 format=2]
|
||||
|
||||
[ext_resource path="res://addons/godot_xterm/native/godotxtermnative.gdnlib" type="GDNativeLibrary" id=1]
|
||||
|
||||
[resource]
|
||||
class_name = "PTYUnix"
|
||||
library = ExtResource( 1 )
|
Loading…
Add table
Add a link
Reference in a new issue