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

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

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