diff --git a/package.json b/package.json index 5f5b9ad5..b25d23d6 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,12 @@ "vite": "^6.0.1", "vite-plugin-cp": "^4.0.8", "vite-tsconfig-paths": "^5.1.0", - "winston": "^3.17.0" + "winston": "^3.17.0", + "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", + "@ffmpeg.wasm/main": "^0.13.1" }, "dependencies": { "@ffmpeg.wasm/core-mt": "^0.13.2", - "@ffmpeg.wasm/main": "^0.13.1", - "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "express": "^5.0.0", "express-rate-limit": "^7.5.0", "piscina": "^4.7.0", diff --git a/src/native/pty/darwin.win64/pty.node b/src/native/pty/darwin.win64/pty.node new file mode 100644 index 00000000..db6e9fd8 Binary files /dev/null and b/src/native/pty/darwin.win64/pty.node differ diff --git a/src/native/pty/darwin.win64/spawn-helper b/src/native/pty/darwin.win64/spawn-helper new file mode 100644 index 00000000..56bd1b59 Binary files /dev/null and b/src/native/pty/darwin.win64/spawn-helper differ diff --git a/src/native/pty/darwin.x64/pty.node b/src/native/pty/darwin.x64/pty.node new file mode 100644 index 00000000..001b59a5 Binary files /dev/null and b/src/native/pty/darwin.x64/pty.node differ diff --git a/src/native/pty/darwin.x64/spawn-helper b/src/native/pty/darwin.x64/spawn-helper new file mode 100644 index 00000000..852a6bdc Binary files /dev/null and b/src/native/pty/darwin.x64/spawn-helper differ diff --git a/src/native/pty/linux.arm64/pty.node b/src/native/pty/linux.arm64/pty.node new file mode 100644 index 00000000..0dcd051c Binary files /dev/null and b/src/native/pty/linux.arm64/pty.node differ diff --git a/src/native/pty/linux.x64/pty.node b/src/native/pty/linux.x64/pty.node new file mode 100644 index 00000000..08961b74 Binary files /dev/null and b/src/native/pty/linux.x64/pty.node differ diff --git a/src/native/pty/win32.x64/conpty.node b/src/native/pty/win32.x64/conpty.node new file mode 100644 index 00000000..17226863 Binary files /dev/null and b/src/native/pty/win32.x64/conpty.node differ diff --git a/src/native/pty/win32.x64/conpty_console_list.node b/src/native/pty/win32.x64/conpty_console_list.node new file mode 100644 index 00000000..f7cba591 Binary files /dev/null and b/src/native/pty/win32.x64/conpty_console_list.node differ diff --git a/src/native/pty/win32.x64/pty.node b/src/native/pty/win32.x64/pty.node new file mode 100644 index 00000000..519db1c6 Binary files /dev/null and b/src/native/pty/win32.x64/pty.node differ diff --git a/src/native/pty/win32.x64/winpty-agent.exe b/src/native/pty/win32.x64/winpty-agent.exe new file mode 100644 index 00000000..cd7dca46 Binary files /dev/null and b/src/native/pty/win32.x64/winpty-agent.exe differ diff --git a/src/native/pty/win32.x64/winpty.dll b/src/native/pty/win32.x64/winpty.dll new file mode 100644 index 00000000..55b4a14a Binary files /dev/null and b/src/native/pty/win32.x64/winpty.dll differ diff --git a/src/pty/index.ts b/src/pty/index.ts new file mode 100644 index 00000000..a8c9cff5 --- /dev/null +++ b/src/pty/index.ts @@ -0,0 +1,33 @@ +import type { ITerminal, IPtyOpenOptions, IPtyForkOptions, IWindowsPtyForkOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import type { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { WindowsTerminal } from './windowsTerminal'; +import { UnixTerminal } from './unixTerminal'; +import { fileURLToPath } from 'node:url'; +import path, { dirname } from 'node:path'; + +let terminalCtor: typeof WindowsTerminal | typeof UnixTerminal; + +if (process.platform === 'win32') { + terminalCtor = WindowsTerminal; +} else { + terminalCtor = UnixTerminal; +} + +export function spawn(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal { + return new terminalCtor(file, args, opt); +} + +export function open(options: IPtyOpenOptions): ITerminal { + return terminalCtor.open(options) as ITerminal; +} +export function require_dlopen(modulename: string) { + const module = { exports: {} }; + const import__dirname = dirname(fileURLToPath(import.meta.url)); + process.dlopen(module, path.join(import__dirname, modulename)); + return module.exports as any; +} +/** + * Expose the native API when not Windows, note that this is not public API and + * could be removed at any time. + */ +export const native = (process.platform !== 'win32' ? require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node') : null); diff --git a/src/pty/native.d.ts b/src/pty/native.d.ts new file mode 100644 index 00000000..46821efb --- /dev/null +++ b/src/pty/native.d.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +interface IConptyNative { + startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, conptyInheritCursor: boolean, useConptyDll: boolean): IConptyProcess; + connect(ptyId: number, commandLine: string, cwd: string, env: string[], onExitCallback: (exitCode: number) => void): { pid: number }; + resize(ptyId: number, cols: number, rows: number, useConptyDll: boolean): void; + clear(ptyId: number, useConptyDll: boolean): void; + kill(ptyId: number, useConptyDll: boolean): void; + } + + interface IWinptyNative { + startProcess(file: string, commandLine: string, env: string[], cwd: string, cols: number, rows: number, debug: boolean): IWinptyProcess; + resize(pid: number, cols: number, rows: number): void; + kill(pid: number, innerPid: number): void; + getProcessList(pid: number): number[]; + getExitCode(innerPid: number): number; + } + + interface IUnixNative { + fork(file: string, args: string[], parsedEnv: string[], cwd: string, cols: number, rows: number, uid: number, gid: number, useUtf8: boolean, helperPath: string, onExitCallback: (code: number, signal: number) => void): IUnixProcess; + open(cols: number, rows: number): IUnixOpenProcess; + process(fd: number, pty?: string): string; + resize(fd: number, cols: number, rows: number): void; + } + + interface IConptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; + } + + interface IWinptyProcess { + pty: number; + fd: number; + conin: string; + conout: string; + pid: number; + innerPid: number; + } + + interface IUnixProcess { + fd: number; + pid: number; + pty: string; + } + + interface IUnixOpenProcess { + master: number; + slave: number; + pty: string; + } diff --git a/src/pty/node-pty.d.ts b/src/pty/node-pty.d.ts new file mode 100644 index 00000000..0a61f88c --- /dev/null +++ b/src/pty/node-pty.d.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2017, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +declare module '@/pty' { + /** + * Forks a process as a pseudoterminal. + * @param file The file to launch. + * @param args The file's arguments as argv (string[]) or in a pre-escaped CommandLine format + * (string). Note that the CommandLine option is only available on Windows and is expected to be + * escaped properly. + * @param options The options of the terminal. + * @see CommandLineToArgvW https://msdn.microsoft.com/en-us/library/windows/desktop/bb776391(v=vs.85).aspx + * @see Parsing C++ Comamnd-Line Arguments https://msdn.microsoft.com/en-us/library/17w5ykft.aspx + * @see GetCommandLine https://msdn.microsoft.com/en-us/library/windows/desktop/ms683156.aspx + */ + export function spawn(file: string, args: string[] | string, options: IPtyForkOptions | IWindowsPtyForkOptions): IPty; + + export interface IBasePtyForkOptions { + + /** + * Name of the terminal to be set in environment ($TERM variable). + */ + name?: string; + + /** + * Number of intial cols of the pty. + */ + cols?: number; + + /** + * Number of initial rows of the pty. + */ + rows?: number; + + /** + * Working directory to be set for the child program. + */ + cwd?: string; + + /** + * Environment to be set for the child program. + */ + env?: { [key: string]: string | undefined }; + + /** + * 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 (Buffer type). + * By default 'utf8' is assumed, to unset it explicitly set it to `null`. + */ + encoding?: string | null; + + /** + * (EXPERIMENTAL) + * Whether to enable flow control handling (false by default). If enabled a message of `flowControlPause` + * will pause the socket and thus blocking the child program execution due to buffer back pressure. + * A message of `flowControlResume` 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 `flowControlPause` and `flowControlResume` messages are not forwarded to + * the underlying pseudoterminal. + */ + handleFlowControl?: boolean; + + /** + * (EXPERIMENTAL) + * The string that should pause the pty when `handleFlowControl` is true. Default is XOFF ('\x13'). + */ + flowControlPause?: string; + + /** + * (EXPERIMENTAL) + * The string that should resume the pty when `handleFlowControl` is true. Default is XON ('\x11'). + */ + flowControlResume?: string; + } + + export interface IPtyForkOptions extends IBasePtyForkOptions { + /** + * Security warning: use this option with great caution, + * as opened file descriptors with higher privileges might leak to the child program. + */ + uid?: number; + gid?: number; + } + + export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + /** + * Whether to use the ConPTY system on Windows. When this is not set, ConPTY will be used when + * the Windows build number is >= 18309 (instead of winpty). Note that ConPTY is available from + * build 17134 but is too unstable to enable by default. + * + * This setting does nothing on non-Windows. + */ + useConpty?: boolean; + + /** + * (EXPERIMENTAL) + * + * Whether to use the conpty.dll shipped with the node-pty package instead of the one built into + * Windows. Defaults to false. + */ + useConptyDll?: boolean; + + /** + * Whether to use PSEUDOCONSOLE_INHERIT_CURSOR in conpty. + * @see https://docs.microsoft.com/en-us/windows/console/createpseudoconsole + */ + conptyInheritCursor?: boolean; + } + + /** + * An interface representing a pseudoterminal, on Windows this is emulated via the winpty library. + */ + export interface IPty { + /** + * The process ID of the outer process. + */ + readonly pid: number; + + /** + * The column size in characters. + */ + readonly cols: number; + + /** + * The row size in characters. + */ + readonly rows: number; + + /** + * The title of the active process. + */ + readonly process: string; + + /** + * (EXPERIMENTAL) + * Whether to handle flow control. Useful to disable/re-enable flow control during runtime. + * Use this for binary data that is likely to contain the `flowControlPause` string by accident. + */ + handleFlowControl: boolean; + + /** + * Adds an event listener for when a data event fires. This happens when data is returned from + * the pty. + * @returns an `IDisposable` to stop listening. + */ + readonly onData: IEvent; + + /** + * Adds an event listener for when an exit event fires. This happens when the pty exits. + * @returns an `IDisposable` to stop listening. + */ + readonly onExit: IEvent<{ exitCode: number, signal?: number }>; + + /** + * Resizes the dimensions of the pty. + * @param columns The number of columns to use. + * @param rows The number of rows to use. + */ + resize(columns: number, rows: number): void; + + // Re-added this interface as homebridge-config-ui-x leverages it https://github.com/microsoft/node-pty/issues/282 + + /** + * Adds a listener to the data event, fired when data is returned from the pty. + * @param event The name of the event. + * @param listener The callback function. + * @deprecated Use IPty.onData + */ + on(event: 'data', listener: (data: string) => void): void; + + /** + * Adds a listener to the exit event, fired when the pty exits. + * @param event The name of the event. + * @param listener The callback function, exitCode is the exit code of the process and signal is + * the signal that triggered the exit. signal is not supported on Windows. + * @deprecated Use IPty.onExit + */ + on(event: 'exit', listener: (exitCode: number, signal?: number) => void): void; + + /** + * Clears the pty's internal representation of its buffer. This is a no-op + * unless on Windows/ConPTY. This is useful if the buffer is cleared on the + * frontend in order to synchronize state with the backend to avoid ConPTY + * possibly reprinting the screen. + */ + clear(): void; + + /** + * Writes data to the pty. + * @param data The data to write. + */ + write(data: string): void; + + /** + * Kills the pty. + * @param signal The signal to use, defaults to SIGHUP. This parameter is not supported on + * Windows. + * @throws Will throw when signal is used on Windows. + */ + kill(signal?: string): void; + + /** + * Pauses the pty for customizable flow control. + */ + pause(): void; + + /** + * Resumes the pty for customizable flow control. + */ + resume(): void; + } + + /** + * An object that can be disposed via a dispose function. + */ + export interface IDisposable { + dispose(): void; + } + + /** + * An event that can be listened to. + * @returns an `IDisposable` to stop listening. + */ + export interface IEvent { + (listener: (e: T) => any): IDisposable; + } + } + \ No newline at end of file diff --git a/src/pty/prebuild-loader.ts b/src/pty/prebuild-loader.ts new file mode 100644 index 00000000..9564a88d --- /dev/null +++ b/src/pty/prebuild-loader.ts @@ -0,0 +1,17 @@ +import { require_dlopen } from '.'; + +let pty: any; + +try { + pty = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node'); +} catch (outerError) { + try { + pty = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node'); + } catch (innerError) { + console.error('innerError', innerError); + // Re-throw the exception from the Release require if the Debug require fails as well + throw outerError; + } +} + +export default pty; diff --git a/src/pty/unixTerminal.ts b/src/pty/unixTerminal.ts new file mode 100644 index 00000000..b119b47f --- /dev/null +++ b/src/pty/unixTerminal.ts @@ -0,0 +1,296 @@ +/* eslint-disable prefer-rest-params */ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +import * as net from 'net'; +import * as path from 'path'; +import * as tty from 'tty'; +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from '@homebridge/node-pty-prebuilt-multiarch/src/terminal'; +import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils'; + +import pty from './prebuild-loader'; +let helperPath: string; +helperPath = '../build/Release/spawn-helper'; + +helperPath = path.resolve(__dirname, helperPath); +helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); + +const DEFAULT_FILE = 'sh'; +const DEFAULT_NAME = 'xterm'; +const DESTROY_SOCKET_TIMEOUT_MS = 200; + +export class UnixTerminal extends Terminal { + protected _fd: number; + protected _pty: string; + + protected _file: string; + protected _name: string; + + protected _readable: boolean; + protected _writable: boolean; + + private _boundClose: boolean = false; + private _emittedClose: boolean = false; + private _master: net.Socket | undefined; + private _slave: net.Socket | undefined; + + public get master(): net.Socket | undefined { return this._master; } + public get slave(): net.Socket | undefined { return this._slave; } + + constructor(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions) { + super(opt); + + if (typeof args === 'string') { + throw new Error('args as a string is not supported on unix.'); + } + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const uid = opt.uid ?? -1; + const gid = opt.gid ?? -1; + const env: IProcessEnv = assign({}, opt.env); + + if (opt.env === process.env) { + this._sanitizeEnv(env); + } + + const cwd = opt.cwd || process.cwd(); + env.PWD = cwd; + const name = opt.name || env.TERM || DEFAULT_NAME; + env.TERM = name; + const parsedEnv = this._parseEnv(env); + + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + const onexit = (code: number, signal: number): void => { + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!this._emittedClose) { + if (this._boundClose) { + return; + } + this._boundClose = true; + // From macOS High Sierra 10.13.2 sometimes the socket never gets + // closed. A timeout is applied here to avoid the terminal never being + // destroyed when this occurs. + let timeout: NodeJS.Timeout | null = setTimeout(() => { + timeout = null; + // Destroying the socket now will cause the close event to fire + this._socket.destroy(); + }, DESTROY_SOCKET_TIMEOUT_MS); + this.once('close', () => { + if (timeout !== null) { + clearTimeout(timeout); + } + this.emit('exit', code, signal); + }); + return; + } + this.emit('exit', code, signal); + }; + + // fork + const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + + this._socket = new tty.ReadStream(term.fd); + if (encoding !== null) { + this._socket.setEncoding(encoding as BufferEncoding); + } + + // setup + this._socket.on('error', (err: any) => { + // NOTE: fs.ReadStream gets EAGAIN twice at first: + if (err.code) { + if (~err.code.indexOf('EAGAIN')) { + return; + } + } + + // close + this._close(); + // EIO on exit from fs.ReadStream: + if (!this._emittedClose) { + this._emittedClose = true; + this.emit('close'); + } + + // EIO, happens when someone closes our child process: the only process in + // the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) { + return; + } + } + + // throw anything else + if (this.listeners('error').length < 2) { + throw err; + } + }); + + this._pid = term.pid; + this._fd = term.fd; + this._pty = term.pty; + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._socket.on('close', () => { + if (this._emittedClose) { + return; + } + this._emittedClose = true; + this._close(); + this.emit('close'); + }); + + this._forwardEvents(); + } + + protected _write(data: string): void { + this._socket.write(data); + } + + /* Accessors */ + get fd(): number { return this._fd; } + get ptsName(): string { return this._pty; } + + /** + * openpty + */ + + public static open(opt: IPtyOpenOptions): UnixTerminal { + const self: UnixTerminal = Object.create(UnixTerminal.prototype); + opt = opt || {}; + + if (arguments.length > 1) { + opt = { + cols: arguments[1], + rows: arguments[2] + }; + } + + const cols = opt.cols || DEFAULT_COLS; + const rows = opt.rows || DEFAULT_ROWS; + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + + // open + const term: IUnixOpenProcess = pty.open(cols, rows); + + self._master = new tty.ReadStream(term.master); + if (encoding !== null) { + self._master.setEncoding(encoding as BufferEncoding); + } + self._master.resume(); + + self._slave = new tty.ReadStream(term.slave); + if (encoding !== null) { + self._slave.setEncoding(encoding as BufferEncoding); + } + self._slave.resume(); + + self._socket = self._master; + self._pid = -1; + self._fd = term.master; + self._pty = term.pty; + + self._file = process.argv[0] || 'node'; + self._name = process.env.TERM || ''; + + self._readable = true; + self._writable = true; + + self._socket.on('error', err => { + self._close(); + if (self.listeners('error').length < 2) { + throw err; + } + }); + + self._socket.on('close', () => { + self._close(); + }); + + return self; + } + + public destroy(): void { + this._close(); + + // Need to close the read stream so node stops reading a dead file + // descriptor. Then we can safely SIGHUP the shell. + this._socket.once('close', () => { + this.kill('SIGHUP'); + }); + + this._socket.destroy(); + } + + public kill(signal?: string): void { + try { + process.kill(this.pid, signal || 'SIGHUP'); + } catch (e) { /* swallow */ } + } + + /** + * Gets the name of the process. + */ + public get process(): string { + if (process.platform === 'darwin') { + const title = pty.process(this._fd); + return (title !== 'kernel_task' ) ? title : this._file; + } + + return pty.process(this._fd, this._pty) || this._file; + } + + /** + * TTY + */ + + public resize(cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + pty.resize(this._fd, cols, rows); + this._cols = cols; + this._rows = rows; + } + + public clear(): void { + + } + + private _sanitizeEnv(env: IProcessEnv): void { + // Make sure we didn't start our server from inside tmux. + delete env['TMUX']; + delete env['TMUX_PANE']; + + // Make sure we didn't start our server from inside screen. + // http://web.mit.edu/gnu/doc/html/screen_20.html + delete env['STY']; + delete env['WINDOW']; + + // Delete some variables that might confuse our terminal. + delete env['WINDOWID']; + delete env['TERMCAP']; + delete env['COLUMNS']; + delete env['LINES']; + } +} diff --git a/src/pty/windowsConoutConnection.ts b/src/pty/windowsConoutConnection.ts new file mode 100644 index 00000000..e1d303a1 --- /dev/null +++ b/src/pty/windowsConoutConnection.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { Worker } from 'worker_threads'; +import { Socket } from 'net'; +import { IDisposable } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { IWorkerData, ConoutWorkerMessage, getWorkerPipeName } from '@homebridge/node-pty-prebuilt-multiarch/src/shared/conout'; +import { dirname, join } from 'path'; +import { IEvent, EventEmitter2 } from '@homebridge/node-pty-prebuilt-multiarch/src/eventEmitter2'; +import { fileURLToPath } from 'node:url'; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the worker and sockets. The timer will be reset if a new data event comes in after + * the timer has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * Connects to and manages the lifecycle of the conout socket. This socket must be drained on + * another thread in order to avoid deadlocks where Conpty waits for the out socket to drain + * when `ClosePseudoConsole` is called. This happens when data is being written to the terminal when + * the pty is closed. + * + * See also: + * - https://github.com/microsoft/node-pty/issues/375 + * - https://github.com/microsoft/vscode/issues/76548 + * - https://github.com/microsoft/terminal/issues/1810 + * - https://docs.microsoft.com/en-us/windows/console/closepseudoconsole + */ +export class ConoutConnection implements IDisposable { + private _worker: Worker; + private _drainTimeout: NodeJS.Timeout | undefined; + private _isDisposed: boolean = false; + + private _onReady = new EventEmitter2(); + public get onReady(): IEvent { return this._onReady.event; } + + constructor( + private _conoutPipeName: string + ) { + const workerData: IWorkerData = { conoutPipeName: _conoutPipeName }; + const scriptPath = dirname(fileURLToPath(import.meta.url)); + this._worker = new Worker(join(scriptPath, 'worker/conoutSocketWorker.mjs'), { workerData }); + this._worker.on('message', (message: ConoutWorkerMessage) => { + switch (message) { + case ConoutWorkerMessage.READY: + this._onReady.fire(); + return; + default: + console.warn('Unexpected ConoutWorkerMessage', message); + } + }); + } + + dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + // Drain all data from the socket before closing + this._drainDataAndClose(); + } + + connectSocket(socket: Socket): void { + socket.connect(getWorkerPipeName(this._conoutPipeName)); + } + + private _drainDataAndClose(): void { + if (this._drainTimeout) { + clearTimeout(this._drainTimeout); + } + this._drainTimeout = setTimeout(() => this._destroySocket(), FLUSH_DATA_INTERVAL); + } + + private async _destroySocket(): Promise { + await this._worker.terminate(); + } +} diff --git a/src/pty/windowsPtyAgent.ts b/src/pty/windowsPtyAgent.ts new file mode 100644 index 00000000..74b1bfca --- /dev/null +++ b/src/pty/windowsPtyAgent.ts @@ -0,0 +1,326 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Socket } from 'net'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { fork } from 'child_process'; +import { ConoutConnection } from './windowsConoutConnection'; +import { require_dlopen } from '.'; + +let conptyNative: IConptyNative; +let winptyNative: IWinptyNative; + +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the socket. The timer will be reset if a new data event comes in after the timer + * has started. + */ +const FLUSH_DATA_INTERVAL = 1000; + +/** + * This agent sits between the WindowsTerminal class and provides a common interface for both conpty + * and winpty. + */ +export class WindowsPtyAgent { + private _inSocket: Socket; + private _outSocket: Socket; + private _pid: number = 0; + private _innerPid: number = 0; + private _closeTimeout: NodeJS.Timer | undefined; + private _exitCode: number | undefined; + private _conoutSocketWorker: ConoutConnection; + + private _fd: any; + private _pty: number; + private _ptyNative: IConptyNative | IWinptyNative; + + public get inSocket(): Socket { return this._inSocket; } + public get outSocket(): Socket { return this._outSocket; } + public get fd(): any { return this._fd; } + public get innerPid(): number { return this._innerPid; } + public get pty(): number { return this._pty; } + + constructor( + file: string, + args: ArgvOrCommandLine, + env: string[], + cwd: string, + cols: number, + rows: number, + debug: boolean, + private _useConpty: boolean | undefined, + private _useConptyDll: boolean = false, + conptyInheritCursor: boolean = false + ) { + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } + if (this._useConpty) { + if (!conptyNative) { + try { + conptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/conpty.node'); + } catch (outerError) { + try { + conptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/conpty.node'); + } catch (innerError) { + console.error('innerError', innerError); + // Re-throw the exception from the Release require if the Debug require fails as well + throw outerError; + } + } + } + } else { + if (!winptyNative) { + try { + winptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node'); + } catch (outerError) { + try { + winptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node'); + } catch (innerError) { + console.error('innerError', innerError); + // Re-throw the exception from the Release require if the Debug require fails as well + throw outerError; + } + } + } + } + this._ptyNative = this._useConpty ? conptyNative : winptyNative; + + // Sanitize input variable. + cwd = path.resolve(cwd); + + // Compose command line + const commandLine = argsToCommandLine(file, args); + + // Open pty session. + let term: IConptyProcess | IWinptyProcess; + if (this._useConpty) { + term = (this._ptyNative as IConptyNative).startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); + } else { + term = (this._ptyNative as IWinptyNative).startProcess(file, commandLine, env, cwd, cols, rows, debug); + this._pid = (term as IWinptyProcess).pid; + this._innerPid = (term as IWinptyProcess).innerPid; + } + + // Not available on windows. + this._fd = term.fd; + + // Generated incremental number that has no real purpose besides using it + // as a terminal id. + this._pty = term.pty; + + // Create terminal pipe IPC channel and forward to a local unix socket. + this._outSocket = new Socket(); + this._outSocket.setEncoding('utf8'); + // The conout socket must be ready out on another thread to avoid deadlocks + this._conoutSocketWorker = new ConoutConnection(term.conout); + this._conoutSocketWorker.onReady(() => { + this._conoutSocketWorker.connectSocket(this._outSocket); + }); + this._outSocket.on('connect', () => { + this._outSocket.emit('ready_datapipe'); + }); + + const inSocketFD = fs.openSync(term.conin, 'w'); + this._inSocket = new Socket({ + fd: inSocketFD, + readable: false, + writable: true + }); + this._inSocket.setEncoding('utf8'); + + if (this._useConpty) { + const connect = (this._ptyNative as IConptyNative).connect(this._pty, commandLine, cwd, env, c => this._$onProcessExit(c)); + this._innerPid = connect.pid; + } + } + + public resize(cols: number, rows: number): void { + if (this._useConpty) { + if (this._exitCode !== undefined) { + throw new Error('Cannot resize a pty that has already exited'); + } + (this._ptyNative as IConptyNative).resize(this._pty, cols, rows, this._useConptyDll); + return; + } + (this._ptyNative as IWinptyNative).resize(this._pid, cols, rows); + } + + public clear(): void { + if (this._useConpty) { + (this._ptyNative as IConptyNative).clear(this._pty, this._useConptyDll); + } + } + + public kill(): void { + this._inSocket.readable = false; + this._outSocket.readable = false; + // Tell the agent to kill the pty, this releases handles to the process + if (this._useConpty) { + this._getConsoleProcessList().then(consoleProcessList => { + consoleProcessList.forEach((pid: number) => { + try { + process.kill(pid); + } catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); + }); + } else { + // Because pty.kill closes the handle, it will kill most processes by itself. + // Process IDs can be reused as soon as all handles to them are + // dropped, so we want to immediately kill the entire console process list. + // If we do not force kill all processes here, node servers in particular + // seem to become detached and remain running (see + // Microsoft/vscode#26807). + const processList: number[] = (this._ptyNative as IWinptyNative).getProcessList(this._pid); + (this._ptyNative as IWinptyNative).kill(this._pid, this._innerPid); + processList.forEach(pid => { + try { + process.kill(pid); + } catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + } + this._conoutSocketWorker.dispose(); + } + + private _getConsoleProcessList(): Promise { + return new Promise(resolve => { + const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [this._innerPid.toString()]); + agent.on('message', message => { + clearTimeout(timeout); + // @ts-expect-error no need to check if it is null + resolve(message.consoleProcessList); + }); + const timeout = setTimeout(() => { + // Something went wrong, just send back the shell PID + agent.kill(); + resolve([this._innerPid]); + }, 5000); + }); + } + + public get exitCode(): number | undefined { + if (this._useConpty) { + return this._exitCode; + } + const winptyExitCode = (this._ptyNative as IWinptyNative).getExitCode(this._innerPid); + return winptyExitCode === -1 ? undefined : winptyExitCode; + } + + private _getWindowsBuildNumber(): number { + const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); + let buildNumber: number = 0; + if (osVersion && osVersion.length === 4) { + buildNumber = parseInt(osVersion[3]); + } + return buildNumber; + } + + private _generatePipeName(): string { + return `conpty-${Math.random() * 10000000}`; + } + + /** + * Triggered from the native side when a contpy process exits. + */ + private _$onProcessExit(exitCode: number): void { + this._exitCode = exitCode; + this._flushDataAndCleanUp(); + this._outSocket.on('data', () => this._flushDataAndCleanUp()); + } + + private _flushDataAndCleanUp(): void { + if (this._closeTimeout) { + // @ts-expect-error no need to check if it is null + clearTimeout(this._closeTimeout); + } + this._closeTimeout = setTimeout(() => this._cleanUpProcess(), FLUSH_DATA_INTERVAL); + } + + private _cleanUpProcess(): void { + this._inSocket.readable = false; + this._outSocket.readable = false; + this._outSocket.destroy(); + } +} + +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from +// winpty project. +export function argsToCommandLine(file: string, args: ArgvOrCommandLine): string { + if (isCommandLine(args)) { + if (args.length === 0) { + return file; + } + return `${argsToCommandLine(file, [])} ${args}`; + } + const argv = [file]; + Array.prototype.push.apply(argv, args); + let result = ''; + for (let argIndex = 0; argIndex < argv.length; argIndex++) { + if (argIndex > 0) { + result += ' '; + } + const arg = argv[argIndex]; + // if it is empty or it contains whitespace and is not already quoted + const hasLopsidedEnclosingQuote = xOr((arg[0] !== '"'), (arg[arg.length - 1] !== '"')); + const hasNoEnclosingQuotes = ((arg[0] !== '"') && (arg[arg.length - 1] !== '"')); + const quote = + arg === '' || + (arg.indexOf(' ') !== -1 || + arg.indexOf('\t') !== -1) && + ((arg.length > 1) && + (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes)); + if (quote) { + result += '"'; + } + let bsCount = 0; + for (let i = 0; i < arg.length; i++) { + const p = arg[i]; + if (p === '\\') { + bsCount++; + } else if (p === '"') { + result += repeatText('\\', bsCount * 2 + 1); + result += '"'; + bsCount = 0; + } else { + result += repeatText('\\', bsCount); + bsCount = 0; + result += p; + } + } + if (quote) { + result += repeatText('\\', bsCount * 2); + result += '"'; + } else { + result += repeatText('\\', bsCount); + } + } + return result; +} + +function isCommandLine(args: ArgvOrCommandLine): args is string { + return typeof args === 'string'; +} + +function repeatText(text: string, count: number): string { + let result = ''; + for (let i = 0; i < count; i++) { + result += text; + } + return result; +} + +function xOr(arg1: boolean, arg2: boolean): boolean { + return ((arg1 && !arg2) || (!arg1 && arg2)); +} diff --git a/src/pty/windowsTerminal.ts b/src/pty/windowsTerminal.ts new file mode 100644 index 00000000..9aae295e --- /dev/null +++ b/src/pty/windowsTerminal.ts @@ -0,0 +1,208 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ + +import { Socket } from 'net'; +import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from '@homebridge/node-pty-prebuilt-multiarch/src/terminal'; +import { WindowsPtyAgent } from './windowsPtyAgent'; +import { IPtyOpenOptions, IWindowsPtyForkOptions } from '@homebridge/node-pty-prebuilt-multiarch/src/interfaces'; +import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types'; +import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils'; + +const DEFAULT_FILE = 'cmd.exe'; +const DEFAULT_NAME = 'Windows Shell'; + +export class WindowsTerminal extends Terminal { + private _isReady: boolean; + private _deferreds: any[]; + private _agent: WindowsPtyAgent; + + constructor(file?: string, args?: ArgvOrCommandLine, opt?: IWindowsPtyForkOptions) { + super(opt); + + this._checkType('args', args, 'string', true); + + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + + if (opt.encoding) { + console.warn('Setting encoding on Windows is not supported'); + } + + const env = assign({}, opt.env); + this._cols = opt.cols || DEFAULT_COLS; + this._rows = opt.rows || DEFAULT_ROWS; + const cwd = opt.cwd || process.cwd(); + const name = opt.name || env.TERM || DEFAULT_NAME; + const parsedEnv = this._parseEnv(env); + + // If the terminal is ready + this._isReady = false; + + // Functions that need to run after `ready` event is emitted. + this._deferreds = []; + + // Create new termal. + this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); + this._socket = this._agent.outSocket; + + // Not available until `ready` event emitted. + this._pid = this._agent.innerPid; + this._fd = this._agent.fd; + this._pty = this._agent.pty; + + // The forked windows terminal is not available until `ready` event is + // emitted. + this._socket.on('ready_datapipe', () => { + + // These events needs to be forwarded. + ['connect', 'data', 'end', 'timeout', 'drain'].forEach(event => { + this._socket.on(event, () => { + + // Wait until the first data event is fired then we can run deferreds. + if (!this._isReady && event === 'data') { + + // Terminal is now ready and we can avoid having to defer method + // calls. + this._isReady = true; + + // Execute all deferred methods + this._deferreds.forEach(fn => { + // NB! In order to ensure that `this` has all its references + // updated any variable that need to be available in `this` before + // the deferred is run has to be declared above this forEach + // statement. + fn.run(); + }); + + // Reset + this._deferreds = []; + + } + }); + }); + + // Shutdown if `error` event is emitted. + this._socket.on('error', err => { + // Close terminal session. + this._close(); + + // EIO, happens when someone closes our child process: the only process + // in the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if ((err).code) { + if (~(err).code.indexOf('errno 5') || ~(err).code.indexOf('EIO')) return; + } + + // Throw anything else. + if (this.listeners('error').length < 2) { + throw err; + } + }); + + // Cleanup after the socket is closed. + this._socket.on('close', () => { + this.emit('exit', this._agent.exitCode); + this._close(); + }); + + }); + + this._file = file; + this._name = name; + + this._readable = true; + this._writable = true; + + this._forwardEvents(); + } + + protected _write(data: string): void { + this._defer(this._doWrite, data); + } + + private _doWrite(data: string): void { + this._agent.inSocket.write(data); + } + + /** + * openpty + */ + + public static open(options?: IPtyOpenOptions): void { + throw new Error('open() not supported on windows, use Fork() instead.'); + } + + /** + * TTY + */ + + public resize(cols: number, rows: number): void { + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + this._deferNoArgs(() => { + this._agent.resize(cols, rows); + this._cols = cols; + this._rows = rows; + }); + } + + public clear(): void { + this._deferNoArgs(() => { + this._agent.clear(); + }); + } + + public destroy(): void { + this._deferNoArgs(() => { + this.kill(); + }); + } + + public kill(signal?: string): void { + this._deferNoArgs(() => { + if (signal) { + throw new Error('Signals not supported on windows.'); + } + this._close(); + this._agent.kill(); + }); + } + + private _deferNoArgs(deferredFn: () => void): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this) + }); + } + + private _defer(deferredFn: (arg: A) => void, arg: A): void { + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + return; + } + + // Queue until terminal is ready. + this._deferreds.push({ + run: () => deferredFn.call(this, arg) + }); + } + + public get process(): string { return this._name; } + public get master(): Socket { throw new Error('master is not supported on Windows'); } + public get slave(): Socket { throw new Error('slave is not supported on Windows'); } +} diff --git a/src/pty/worker/conoutSocketWorker.ts b/src/pty/worker/conoutSocketWorker.ts new file mode 100644 index 00000000..64aeed00 --- /dev/null +++ b/src/pty/worker/conoutSocketWorker.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2020, Microsoft Corporation (MIT License). + */ + +import { parentPort, workerData } from 'worker_threads'; +import { Socket, createServer } from 'net'; +import { ConoutWorkerMessage, IWorkerData, getWorkerPipeName } from '@homebridge/node-pty-prebuilt-multiarch/src/shared/conout'; + +const conoutPipeName = (workerData as IWorkerData).conoutPipeName; +const conoutSocket = new Socket(); +conoutSocket.setEncoding('utf8'); +conoutSocket.connect(conoutPipeName, () => { + const server = createServer(workerSocket => { + conoutSocket.pipe(workerSocket); + }); + server.listen(getWorkerPipeName(conoutPipeName)); + + if (!parentPort) { + throw new Error('worker_threads parentPort is null'); + } + parentPort.postMessage(ConoutWorkerMessage.READY); +}); diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index f2a05426..da052d41 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,3 +1,2 @@ import { NCoreInitShell } from "./base"; - NCoreInitShell(); \ No newline at end of file diff --git a/src/webui/src/terminal/terminal_manager.ts b/src/webui/src/terminal/terminal_manager.ts index f9f7f59d..2d9c6e01 100644 --- a/src/webui/src/terminal/terminal_manager.ts +++ b/src/webui/src/terminal/terminal_manager.ts @@ -4,7 +4,7 @@ import { AuthHelper } from '../helper/SignToken'; import { LogWrapper } from '@/common/log'; import { WebSocket, WebSocketServer } from 'ws'; import os from 'os'; -import { type IPty, spawn as ptySpawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { IPty, spawn as ptySpawn } from '@/pty'; import { randomUUID } from 'crypto'; interface TerminalInstance { diff --git a/vite.config.ts b/vite.config.ts index 1b59f00f..e58b2c61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,9 +10,7 @@ const external = [ 'express', 'qrcode-terminal', 'piscina', - '@ffmpeg.wasm/core-mt', - '@ffmpeg.wasm/main', - '@homebridge/node-pty-prebuilt-multiarch', + '@ffmpeg.wasm/core-mt' ]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); @@ -31,6 +29,7 @@ const UniversalBaseConfigPlugin: PluginOption[] = [ { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/framework/liteloader.cjs', dest: 'dist' }, { src: './src/framework/napcat.cjs', dest: 'dist' }, @@ -53,6 +52,7 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [ { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/framework/liteloader.cjs', dest: 'dist' }, { src: './src/framework/napcat.cjs', dest: 'dist' }, @@ -69,6 +69,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './package.json', dest: 'dist' }, @@ -99,6 +100,7 @@ const UniversalBaseConfig = () => napcat: 'src/universal/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -128,6 +130,7 @@ const ShellBaseConfig = () => napcat: 'src/shell/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -157,6 +160,7 @@ const FrameworkBaseConfig = () => napcat: 'src/framework/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`,