feat: 摇树生成&多平台统一改造

This commit is contained in:
手瓜一十雪
2025-02-03 10:33:10 +08:00
parent 3eb66fa34a
commit b25f9d3bec
24 changed files with 1278 additions and 8 deletions

View File

@@ -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",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

33
src/pty/index.ts Normal file
View File

@@ -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);

54
src/pty/native.d.ts vendored Normal file
View File

@@ -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;
}

231
src/pty/node-pty.d.ts vendored Normal file
View File

@@ -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<string>;
/**
* 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<T> {
(listener: (e: T) => any): IDisposable;
}
}

View File

@@ -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;

296
src/pty/unixTerminal.ts Normal file
View File

@@ -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'];
}
}

View File

@@ -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<void>();
public get onReady(): IEvent<void> { 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<void> {
await this._worker.terminate();
}
}

326
src/pty/windowsPtyAgent.ts Normal file
View File

@@ -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<number[]> {
return new Promise<number[]>(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));
}

208
src/pty/windowsTerminal.ts Normal file
View File

@@ -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 ((<any>err).code) {
if (~(<any>err).code.indexOf('errno 5') || ~(<any>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<A>(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<A>(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'); }
}

View File

@@ -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);
});

View File

@@ -1,3 +1,2 @@
import { NCoreInitShell } from "./base";
NCoreInitShell();

View File

@@ -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 {

View File

@@ -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`,