Merge pull request #61 from unitwk/feature/pty

Feature/pty
This commit is contained in:
unitwk 2024-03-11 10:45:00 +08:00 committed by GitHub
commit ed4795224a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 296 additions and 15 deletions

View File

@ -119,7 +119,11 @@ export class ProcessWrapper extends EventEmitter {
}
}
export function killProcess(pid: string | number, process: ChildProcess, signal?: any) {
export function killProcess(
pid: string | number,
process: { kill(signal?: any): any },
signal?: any
) {
try {
if (os.platform() === "win32") {
execSync(`taskkill /PID ${pid} /T /F`);

View File

@ -60,6 +60,9 @@
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"webpack-node-externals": "^3.0.0"
},
"optionalDependencies": {
"node-pty": "^1.0.0"
}
},
"../common": {
@ -3207,6 +3210,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nan": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz",
"integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==",
"optional": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -3238,6 +3247,16 @@
"node": ">= 12"
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/node-releases": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
@ -7353,6 +7372,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"nan": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz",
"integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==",
"optional": true
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -7378,6 +7403,15 @@
"iconv-lite": "^0.6.2"
}
},
"node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"optional": true,
"requires": {
"nan": "^2.17.0"
}
},
"node-releases": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",

View File

@ -44,6 +44,9 @@
"uuid": "^8.3.2",
"yaml": "^1.10.2"
},
"optionalDependencies": {
"node-pty": "^1.0.0"
},
"devDependencies": {
"@types/archiver": "^5.3.1",
"@types/axios": "^0.14.0",

View File

@ -13,6 +13,9 @@ import PtyStartCommand from "./pty/pty_start";
import PtyStopCommand from "./pty/pty_stop";
import OpenFrpTask from "./task/openfrp";
import RconCommand from "./steam/rcon_command";
import NodePtyStartCommand from "./pty/node_pty_start";
import DockerResizeCommand from "./docker/docker_pty_resize";
import NodePtyResizeCommand from "./pty/node_pty_resize";
// Instance function dispatcher
// Dispatch and assign different functions according to different types
@ -44,20 +47,16 @@ export default class FunctionDispatcher extends InstanceCommand {
}
// Enable emulated terminal mode
if (
instance.config.terminalOption.pty &&
instance.config.terminalOption.ptyWindowCol &&
instance.config.terminalOption.ptyWindowRow &&
instance.config.processType === "general"
) {
instance.setPreset("start", new PtyStartCommand());
if (instance.config.terminalOption.pty && instance.config.processType === "general") {
// instance.setPreset("start", new PtyStartCommand());
instance.setPreset("start", new NodePtyStartCommand());
instance.setPreset("stop", new PtyStopCommand());
instance.setPreset("resize", new NullCommand());
instance.setPreset("resize", new NodePtyResizeCommand());
}
// Whether to enable Docker PTY mode
if (instance.config.processType === "docker") {
instance.setPreset("start", new DockerStartCommand());
instance.setPreset("resize", new NullCommand());
instance.setPreset("resize", new DockerResizeCommand());
}
if (instance.config.enableRcon) {
instance.setPreset("command", new RconCommand());

View File

@ -0,0 +1,24 @@
import Instance from "../../instance/instance";
import InstanceCommand from "../base/command";
import { DockerProcessAdapter } from "./docker_start";
export interface IResizeOptions {
h: number;
w: number;
}
export default class DockerResizeCommand extends InstanceCommand {
constructor() {
super("ResizeTTY");
}
async exec(instance: Instance, size?: IResizeOptions): Promise<any> {
const dockerProcess = instance?.process as Partial<DockerProcessAdapter>;
if (typeof dockerProcess?.container?.resize === "function") {
await dockerProcess?.container?.resize({
h: size.h,
w: size.w
});
}
}
}

View File

@ -0,0 +1,21 @@
import Instance from "../../instance/instance";
import InstanceCommand from "../base/command";
import { NodeProcessAdapter } from "./node_pty_start";
interface IResizeOptions {
h: number;
w: number;
}
export default class NodePtyResizeCommand extends InstanceCommand {
constructor() {
super("ResizeTTY");
}
async exec(instance: Instance, size?: IResizeOptions): Promise<any> {
const dockerProcess = instance.process as Partial<NodeProcessAdapter>;
if (typeof dockerProcess?.resize === "function") {
dockerProcess?.resize(size.w, size.h);
}
}
}

View File

@ -0,0 +1,174 @@
import { $t } from "../../../i18n";
import os from "os";
import Instance from "../../instance/instance";
import logger from "../../../service/log";
import fs from "fs-extra";
import path from "path";
import readline from "readline";
import InstanceCommand from "../base/command";
import { ChildProcess, ChildProcessWithoutNullStreams, spawn } from "child_process";
import { commandStringToArray } from "../base/command_parser";
import FunctionDispatcher from "../dispatcher";
import type { IPty, spawn as spawnType } from "node-pty";
import { killProcess } from "common";
import { EventEmitter } from "koa";
import { IInstanceProcess } from "../../instance/interface";
interface IPtySubProcessCfg {
pid: number;
}
// Error exception at startup
class StartupError extends Error {
constructor(msg: string) {
super(msg);
}
}
// process adapter
export class NodeProcessAdapter extends EventEmitter implements IInstanceProcess {
public pid?: number;
public exitCode: number;
constructor(private process: IPty) {
super();
this.pid = this.process.pid;
this.process.onData((data: string | Buffer) => this.emit("data", data));
this.process.onExit((info) => {
this.exitCode = info.exitCode;
this.emit("exit", info.exitCode);
});
}
public resize(w: number, h: number) {
this.process.resize(w, h);
}
public write(data?: string) {
return this.process.write(data);
}
public kill(s?: any) {
return killProcess(this.pid, this.process);
}
public async destroy() {
try {
// remove all dynamically added event listeners
for (const n of this.eventNames()) this.removeAllListeners(n);
if (this.exitCode) {
this.kill();
}
} catch (error) {}
}
}
export default class NodePtyStartCommand extends InstanceCommand {
constructor() {
super("PtyStartCommand");
}
readPtySubProcessConfig(subProcess: ChildProcessWithoutNullStreams): Promise<IPtySubProcessCfg> {
return new Promise((r, j) => {
const errConfig = {
pid: 0
};
const rl = readline.createInterface({
input: subProcess.stdout,
crlfDelay: Infinity
});
rl.on("line", (line = "") => {
try {
rl.removeAllListeners();
const cfg = JSON.parse(line) as IPtySubProcessCfg;
if (cfg.pid == null) throw new Error("Error");
r(cfg);
} catch (error) {
r(errConfig);
}
});
setTimeout(() => {
r(errConfig);
}, 1000 * 3);
});
}
async exec(instance: Instance, source = "Unknown") {
if (
!instance.config.startCommand ||
!instance.config.cwd ||
!instance.config.ie ||
!instance.config.oe
)
throw new StartupError($t("TXT_CODE_pty_start.cmdErr"));
if (!fs.existsSync(instance.absoluteCwdPath()))
throw new StartupError($t("TXT_CODE_pty_start.cwdNotExist"));
if (!path.isAbsolute(path.normalize(instance.config.cwd)))
throw new StartupError($t("TXT_CODE_pty_start.mustAbsolutePath"));
// PTY mode correctness check
logger.info($t("TXT_CODE_pty_start.startPty", { source: source }));
try {
require.resolve("node-pty");
} catch (e) {
// node-pty not available
instance.println("ERROR", $t("TXT_CODE_pty_start.startErr"));
instance.config.terminalOption.pty = false;
await instance.forceExec(new FunctionDispatcher());
await instance.execPreset("start", source);
return;
}
// command parsing
const commandList = commandStringToArray(instance.config.startCommand);
const commandExeFile = commandList[0];
const commandParameters = commandList.slice(1);
if (commandList.length === 0) {
throw new StartupError($t("TXT_CODE_general_start.cmdEmpty"));
}
logger.info("----------------");
logger.info($t("TXT_CODE_general_start.startInstance", { source: source }));
logger.info($t("TXT_CODE_general_start.instanceUuid", { uuid: instance.instanceUuid }));
logger.info($t("TXT_CODE_general_start.startCmd", { cmdList: JSON.stringify(commandList) }));
logger.info($t("TXT_CODE_general_start.cwd", { cwd: instance.config.cwd }));
logger.info("----------------");
const { spawn } = require("node-pty") as {
spawn: typeof spawnType;
};
logger.info($t("模式仿真终端node-pty"));
const ptyProcess = spawn(commandExeFile, commandParameters, {
name: "xterm-256color",
cols: 170,
rows: 40,
cwd: instance.config.cwd,
env: { ...process.env, TERM: "xterm-256color" },
encoding: null
});
if (!ptyProcess.pid) {
instance.println(
"ERROR",
$t("TXT_CODE_general_start.pidErr", {
startCommand: instance.config.startCommand,
commandExeFile: commandExeFile,
commandParameters: JSON.stringify(commandParameters)
})
);
throw new StartupError($t("TXT_CODE_general_start.startErr"));
}
const processAdapter = new NodeProcessAdapter(ptyProcess);
instance.started(processAdapter);
logger.info(
$t("TXT_CODE_pty_start.startSuccess", {
instanceUuid: instance.instanceUuid,
pid: processAdapter.pid
})
);
instance.println("INFO", $t("TXT_CODE_pty_start.startEmulatedTerminal"));
}
}

View File

@ -111,8 +111,8 @@ export default class Instance extends EventEmitter {
) {
if (this.status() != Instance.STATUS_STOP)
throw new Error($t("TXT_CODE_instanceConf.cantModifyPtyModel"));
if (!fs.existsSync(PTY_PATH) && cfg?.terminalOption?.pty === true)
throw new Error($t("TXT_CODE_instanceConf.ptyNotExist", { path: PTY_PATH }));
// if (!fs.existsSync(PTY_PATH) && cfg?.terminalOption?.pty === true)
// throw new Error($t("TXT_CODE_instanceConf.ptyNotExist", { path: PTY_PATH }));
configureEntityParams(this.config.terminalOption, cfg.terminalOption, "pty", Boolean);
this.forceExec(new FunctionDispatcher());
}
@ -224,8 +224,10 @@ export default class Instance extends EventEmitter {
started(process: IInstanceProcess) {
this.config.lastDatetime = Date.now();
const outputCode = this.config.terminalOption.pty ? "utf-8" : this.config.oe;
process.on("data", (text) => this.emit("data", iconv.decode(text, outputCode)));
process.on("exit", (code) => this.stopped(code));
process.on("data", (text: any) => {
this.emit("data", iconv.decode(text, outputCode));
});
process.on("exit", (code: number) => this.stopped(code));
this.process = process;
this.instanceStatus = Instance.STATUS_RUNNING;
this.emit("open", this);

View File

@ -129,7 +129,7 @@ routerApp.on("stream/resize", async (ctx, data) => {
try {
const instanceUuid = ctx.session?.stream?.instanceUuid;
const instance = InstanceSubsystem.getInstance(instanceUuid);
if (instance.config.processType === "docker") await instance.execPreset("resize", data);
if (instance) await instance.execPreset("resize", data);
} catch (error) {
// protocol.responseError(ctx, error);
}

View File

@ -60,7 +60,12 @@ export function useTerminal() {
const terminal = ref<Terminal>();
const isConnect = ref<boolean>(false);
const socketAddress = ref("");
let fitAddonTask: NodeJS.Timer;
let cachedSize = {
w: 160,
h: 40
};
const execute = async (config: UseTerminalParams) => {
isReady.value = false;
@ -144,6 +149,18 @@ export function useTerminal() {
return socket;
};
const refreshWindowSize = (w: number, h: number) => {
if (cachedSize.h !== h || cachedSize.w !== w) {
cachedSize = {
w,
h
};
socket?.emit("stream/resize", {
data: cachedSize
});
}
};
const initTerminalWindow = (element: HTMLElement) => {
const background = hasBgImage.value ? "#00000000" : "#1e1e1e";
const term = new Terminal({
@ -167,8 +184,11 @@ export function useTerminal() {
term.loadAddon(fitAddon);
term.open(element);
fitAddon.fit();
refreshWindowSize(term.cols - 1, term.rows - 1);
fitAddonTask = setInterval(() => {
fitAddon.fit();
refreshWindowSize(term.cols - 1, term.rows - 1);
// Auto resize pty win size
}, 1000);
term.onData((data) => {