Files
netdesk/host/pty_session.py

113 lines
3.5 KiB
Python

"""Interactive command terminal for the NetDesk host ("cmd").
Spawns a shell and streams its output, so a connected viewer gets a working
console: run commands and see their output live (dir, ipconfig, ping, python
scripts, git, etc.). Uses a pipe rather than a full pseudo-console — that covers
command execution; full-screen TUI apps (vim, a nested shell UI) would need a
ConPTY and are a future enhancement.
Wire protocol (host side):
viewer -> host : {"t":"term.open","tid":N} open a shell
viewer -> host : {"t":"term.data","tid":N,"d":"<text>"} send keystrokes/commands
viewer -> host : {"t":"term.resize","tid":N,"cols":C,"rows":R}
viewer -> host : {"t":"term.close","tid":N}
host -> viewer: {"t":"term.data","tid":N,"d":"<text>"} shell output (streamed)
host -> viewer: {"t":"term.exit","tid":N} shell ended
Developed by Akronlogic.
"""
import os
import subprocess
import threading
CREATE_NO_WINDOW = 0x08000000
class TerminalSession:
"""One shell process. `on_output(text: str)` is invoked from a reader
thread as the shell produces output; it is called once with "" when the
shell exits."""
def __init__(self, on_output, shell=None, cwd=None):
self._on_output = on_output
shell = shell or os.environ.get("COMSPEC", "cmd.exe")
self._p = subprocess.Popen(
shell,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
cwd=cwd,
creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0,
)
self._alive = True
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()
def _pump(self):
try:
while True:
chunk = self._p.stdout.read(4096)
if not chunk:
break
try:
self._on_output(chunk.decode("utf-8", "replace"))
except Exception:
pass
finally:
self._alive = False
try:
self._on_output("") # EOF -> shell closed
except Exception:
pass
def write(self, data):
"""Send keystrokes / a command line (include the trailing \\r\\n)."""
if isinstance(data, str):
data = data.encode("utf-8", "replace")
try:
self._p.stdin.write(data)
self._p.stdin.flush()
return True
except Exception:
return False
def resize(self, cols, rows):
# Pipe mode has no window size; accepted for API parity (ConPTY later).
return None
@property
def alive(self):
return self._alive and self._p.poll() is None
def close(self):
self._alive = False
try:
self._p.stdin.close()
except Exception:
pass
try:
self._p.terminate()
except Exception:
pass
if __name__ == "__main__":
import time
buf = []
term = TerminalSession(on_output=buf.append)
time.sleep(0.3)
term.write("echo NETDESK_TERM_OK_777\r\n")
time.sleep(0.6)
term.write("cd\r\n")
time.sleep(0.4)
term.write("exit\r\n")
time.sleep(0.4)
term.close()
text = "".join(buf)
ok = "NETDESK_TERM_OK_777" in text
print("SELFTEST", "PASS" if ok else "FAIL", "| chars:", len(text))
if not ok:
print(text[:800])