From 799c9be80eabd7dfbf17432a291db0217ef53fd4 Mon Sep 17 00:00:00 2001 From: unitwk Date: Fri, 8 Mar 2024 15:05:40 +0800 Subject: [PATCH 1/4] Feat: Add node-pty --- common/src/process_tools.ts | 6 +- daemon/package-lock.json | 34 ++++ daemon/package.json | 3 + daemon/src/entity/commands/dispatcher.ts | 11 +- .../src/entity/commands/pty/node_pty_start.ts | 170 ++++++++++++++++++ daemon/src/entity/instance/instance.ts | 10 +- 6 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 daemon/src/entity/commands/pty/node_pty_start.ts diff --git a/common/src/process_tools.ts b/common/src/process_tools.ts index fdf4d641..2b19640c 100755 --- a/common/src/process_tools.ts +++ b/common/src/process_tools.ts @@ -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`); diff --git a/daemon/package-lock.json b/daemon/package-lock.json index c9595977..4c425040 100644 --- a/daemon/package-lock.json +++ b/daemon/package-lock.json @@ -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", diff --git a/daemon/package.json b/daemon/package.json index a194d99e..313f340c 100644 --- a/daemon/package.json +++ b/daemon/package.json @@ -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", diff --git a/daemon/src/entity/commands/dispatcher.ts b/daemon/src/entity/commands/dispatcher.ts index 4793d58e..e37d8c7e 100755 --- a/daemon/src/entity/commands/dispatcher.ts +++ b/daemon/src/entity/commands/dispatcher.ts @@ -13,6 +13,7 @@ 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"; // Instance function dispatcher // Dispatch and assign different functions according to different types @@ -44,13 +45,9 @@ 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()); } diff --git a/daemon/src/entity/commands/pty/node_pty_start.ts b/daemon/src/entity/commands/pty/node_pty_start.ts new file mode 100644 index 00000000..62edfe20 --- /dev/null +++ b/daemon/src/entity/commands/pty/node_pty_start.ts @@ -0,0 +1,170 @@ +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 +class ProcessAdapter 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 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 { + 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 ProcessAdapter(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")); + } +} diff --git a/daemon/src/entity/instance/instance.ts b/daemon/src/entity/instance/instance.ts index e5d55608..f60789d9 100755 --- a/daemon/src/entity/instance/instance.ts +++ b/daemon/src/entity/instance/instance.ts @@ -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); From 3799186b52963400d5450a46134c3b0a21ce5546 Mon Sep 17 00:00:00 2001 From: unitwk Date: Fri, 8 Mar 2024 15:19:44 +0800 Subject: [PATCH 2/4] Feat: add resize --- .../commands/docker/docker_pty_resize.ts | 24 +++++++++++++++++++ .../entity/commands/pty/node_pty_resize.ts | 21 ++++++++++++++++ .../src/entity/commands/pty/node_pty_start.ts | 8 +++++-- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 daemon/src/entity/commands/docker/docker_pty_resize.ts create mode 100644 daemon/src/entity/commands/pty/node_pty_resize.ts diff --git a/daemon/src/entity/commands/docker/docker_pty_resize.ts b/daemon/src/entity/commands/docker/docker_pty_resize.ts new file mode 100644 index 00000000..2a8c1fa4 --- /dev/null +++ b/daemon/src/entity/commands/docker/docker_pty_resize.ts @@ -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 { + const dockerProcess = instance?.process as Partial; + if (typeof dockerProcess?.container?.resize === "function") { + await dockerProcess?.container?.resize({ + h: size.h, + w: size.w + }); + } + } +} diff --git a/daemon/src/entity/commands/pty/node_pty_resize.ts b/daemon/src/entity/commands/pty/node_pty_resize.ts new file mode 100644 index 00000000..ee3b5fb6 --- /dev/null +++ b/daemon/src/entity/commands/pty/node_pty_resize.ts @@ -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 { + const dockerProcess = instance.process as Partial; + if (typeof dockerProcess?.resize === "function") { + dockerProcess?.resize(size.w, size.h); + } + } +} diff --git a/daemon/src/entity/commands/pty/node_pty_start.ts b/daemon/src/entity/commands/pty/node_pty_start.ts index 62edfe20..bb98d32b 100644 --- a/daemon/src/entity/commands/pty/node_pty_start.ts +++ b/daemon/src/entity/commands/pty/node_pty_start.ts @@ -25,7 +25,7 @@ class StartupError extends Error { } // process adapter -class ProcessAdapter extends EventEmitter implements IInstanceProcess { +export class NodeProcessAdapter extends EventEmitter implements IInstanceProcess { public pid?: number; public exitCode: number; @@ -39,6 +39,10 @@ class ProcessAdapter extends EventEmitter implements IInstanceProcess { }); } + public resize(w: number, h: number) { + this.process.resize(w, h); + } + public write(data?: string) { return this.process.write(data); } @@ -157,7 +161,7 @@ export default class NodePtyStartCommand extends InstanceCommand { throw new StartupError($t("TXT_CODE_general_start.startErr")); } - const processAdapter = new ProcessAdapter(ptyProcess); + const processAdapter = new NodeProcessAdapter(ptyProcess); instance.started(processAdapter); logger.info( $t("TXT_CODE_pty_start.startSuccess", { From c90bdcd9cb64d239368a1e058e8b57d932b9efab Mon Sep 17 00:00:00 2001 From: unitwk Date: Fri, 8 Mar 2024 15:37:43 +0800 Subject: [PATCH 3/4] Feat: Auto Resize win --- daemon/src/entity/commands/dispatcher.ts | 6 ++++-- daemon/src/routers/stream_router.ts | 3 ++- frontend/src/hooks/useTerminal.ts | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/daemon/src/entity/commands/dispatcher.ts b/daemon/src/entity/commands/dispatcher.ts index e37d8c7e..0d3293fd 100755 --- a/daemon/src/entity/commands/dispatcher.ts +++ b/daemon/src/entity/commands/dispatcher.ts @@ -14,6 +14,8 @@ 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 @@ -49,12 +51,12 @@ export default class FunctionDispatcher extends InstanceCommand { // 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()); diff --git a/daemon/src/routers/stream_router.ts b/daemon/src/routers/stream_router.ts index c94aa3d4..06b8ecae 100755 --- a/daemon/src/routers/stream_router.ts +++ b/daemon/src/routers/stream_router.ts @@ -127,9 +127,10 @@ routerApp.on("stream/write", async (ctx, data) => { // handle terminal resize routerApp.on("stream/resize", async (ctx, data) => { try { + console.debug("REV:", data); 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); } diff --git a/frontend/src/hooks/useTerminal.ts b/frontend/src/hooks/useTerminal.ts index 7b114737..c5a9f5de 100644 --- a/frontend/src/hooks/useTerminal.ts +++ b/frontend/src/hooks/useTerminal.ts @@ -60,7 +60,12 @@ export function useTerminal() { const terminal = ref(); const isConnect = ref(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) => { From f0fbef118b37bda84bdc2b81a7fc573b22e6f481 Mon Sep 17 00:00:00 2001 From: unitwk Date: Fri, 8 Mar 2024 15:50:52 +0800 Subject: [PATCH 4/4] Feat: Auto Resize win --- daemon/src/routers/stream_router.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/src/routers/stream_router.ts b/daemon/src/routers/stream_router.ts index 06b8ecae..986fda43 100755 --- a/daemon/src/routers/stream_router.ts +++ b/daemon/src/routers/stream_router.ts @@ -127,7 +127,6 @@ routerApp.on("stream/write", async (ctx, data) => { // handle terminal resize routerApp.on("stream/resize", async (ctx, data) => { try { - console.debug("REV:", data); const instanceUuid = ctx.session?.stream?.instanceUuid; const instance = InstanceSubsystem.getInstance(instanceUuid); if (instance) await instance.execPreset("resize", data);