Merge pull request #745 from NapNeko/dev-terminal

feat: 文件管理 & 系统终端
This commit is contained in:
手瓜一十雪
2025-02-03 11:13:14 +08:00
committed by GitHub
53 changed files with 3928 additions and 156 deletions

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,10 @@
import { require_dlopen } from '.';
export function pty_loader() {
let pty: any;
try {
pty = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node');
} catch (outerError) {
pty = undefined;
}
return pty;
};

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

@@ -0,0 +1,297 @@
/* 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_loader } from './prebuild-loader';
export const pty = pty_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();
}
}

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

@@ -0,0 +1,306 @@
/**
* 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) {
conptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/conpty.node');
}
} else {
if (!winptyNative) {
winptyNative = require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node');
}
}
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

@@ -3,6 +3,7 @@
*/
import express from 'express';
import { createServer } from 'http';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config';
@@ -11,10 +12,11 @@ import { cors } from '@webapi/middleware/cors';
import { createUrl } from '@webapi/utils/url';
import { sendSuccess } from '@webapi/utils/response';
import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
// 实例化Express
const app = express();
const server = createServer(app);
/**
* 初始化并启动WebUI服务。
* 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。
@@ -45,6 +47,10 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------挂载路由------------
// 挂载静态路由(前端),路径为 [/前缀]/webui
app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
});
// 挂载API接口
app.use('/api', ALLRouter);
// 所有剩下的请求都转到静态页面
@@ -61,7 +67,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------路由挂载结束------------
// ------------启动服务------------
app.listen(config.port, config.host, async () => {
server.listen(config.port, config.host, async () => {
// 启动后打印出相关地址
const port = config.port.toString(),
searchParams = { token: config.token };

261
src/webui/src/api/File.ts Normal file
View File

@@ -0,0 +1,261 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const isWindows = os.platform() === 'win32';
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
// Windows 驱动器字母 (A-Z)
const drives: string[] = [];
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fs.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
continue;
}
}
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
}
return path.normalize(inputPath);
};
interface FileInfo {
name: string;
isDirectory: boolean;
size: number;
mtime: Date;
}
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
const stat = await fs.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
return false;
}
};
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query.onlyDirectory === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
const drives = await getRootDirs();
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fs.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
size: 0,
mtime: stat.mtime,
};
} catch {
return {
name: drive,
isDirectory: true,
size: 0,
mtime: new Date(),
};
}
})
);
return sendSuccess(res, driveInfos);
}
const files = await fs.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
// 跳过系统文件
if (SYSTEM_FILES.has(file)) continue;
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fs.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
size: stat.size,
mtime: stat.mtime,
});
} catch (error) {
// 忽略无法访问的文件
// console.warn(`无法访问文件 ${file}:`, error);
continue;
}
}
// 如果请求参数 onlyDirectory 为 true则只返回目录信息
if (onlyDirectory) {
fileInfos = fileInfos.filter((info) => info.isDirectory);
}
return sendSuccess(res, fileInfos);
} catch (error) {
console.error('读取目录失败:', error);
return sendError(res, '读取目录失败');
}
};
// 创建目录
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
return sendError(res, '同名目录已存在');
}
await fs.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
}
};
// 删除文件/目录
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除失败');
}
};
// 批量删除文件/目录
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量删除失败');
}
};
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query.path as string);
const content = await fs.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
}
};
// 写入文件内容
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fs.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
}
};
// 创建新文件
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
return sendError(res, '同名文件已存在');
}
await fs.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
}
};
// 重命名文件/目录
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fs.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
}
};
// 移动文件/目录
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
}
};
// 批量移动
export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};

View File

@@ -2,6 +2,7 @@ import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import { WebUiConfigWrapper } from '../helper/config';
import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
@@ -35,3 +36,25 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
logSubscription.unsubscribe(listener);
});
};
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
try {
const { id } = terminalManager.createTerminal();
return sendSuccess(res, { id });
} catch (error) {
console.error('Failed to create terminal:', error);
return sendError(res, '创建终端失败');
}
};
export const GetTerminalListHandler: RequestHandler = (_, res) => {
const list = terminalManager.getTerminalList();
return sendSuccess(res, list);
};
export const CloseTerminalHandler: RequestHandler = (req, res) => {
const id = req.params.id;
terminalManager.closeTerminal(id);
return sendSuccess(res, {});
};

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import {
ListFilesHandler,
CreateDirHandler,
DeleteHandler,
ReadFileHandler,
WriteFileHandler,
CreateFileHandler,
BatchDeleteHandler, // 添加这一行
RenameHandler,
MoveHandler,
BatchMoveHandler,
} from '../api/File';
const router = Router();
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟内
max: 60, // 最大60个请求
});
router.use(apiLimiter);
router.get('/list', ListFilesHandler);
router.post('/mkdir', CreateDirHandler);
router.post('/delete', DeleteHandler);
router.get('/read', ReadFileHandler);
router.post('/write', WriteFileHandler);
router.post('/create', CreateFileHandler);
router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
export { router as FileRouter };

View File

@@ -1,13 +1,23 @@
import { Router } from 'express';
import { LogHandler, LogListHandler, LogRealTimeHandler } from '../api/Log';
import {
LogHandler,
LogListHandler,
LogRealTimeHandler,
CreateTerminalHandler,
GetTerminalListHandler,
CloseTerminalHandler,
} from '../api/Log';
const router = Router();
// router:读取日志内容
router.get('/GetLog', LogHandler);
// router:读取日志列表
router.get('/GetLogList', LogListHandler);
// router:实时日志
// 日志相关路由
router.get('/GetLog', LogHandler);
router.get('/GetLogList', LogListHandler);
router.get('/GetLogRealTime', LogRealTimeHandler);
// 终端相关路由
router.get('/terminal/list', GetTerminalListHandler);
router.post('/terminal/create', CreateTerminalHandler);
router.post('/terminal/:id/close', CloseTerminalHandler);
export { router as LogRouter };

View File

@@ -12,6 +12,7 @@ import { QQLoginRouter } from '@webapi/router/QQLogin';
import { AuthRouter } from '@webapi/router/auth';
import { LogRouter } from '@webapi/router/Log';
import { BaseRouter } from '@webapi/router/Base';
import { FileRouter } from './File';
const router = Router();
@@ -32,5 +33,7 @@ router.use('/QQLogin', QQLoginRouter);
router.use('/OB11Config', OB11ConfigRouter);
// router:日志相关路由
router.use('/Log', LogRouter);
// file:文件相关路由
router.use('/File', FileRouter);
export { router as ALLRouter };

View File

@@ -0,0 +1,21 @@
import path from 'path';
Object.defineProperty(global, '__dirname', {
get() {
const err = new Error();
const stack = err.stack?.split('\n') || [];
let callerFile = '';
// 遍历错误堆栈,跳过当前文件所在行
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
for (const line of stack) {
const match = line.match(/\((.*):\d+:\d+\)/);
if (match) {
callerFile = match[1];
if (!callerFile.includes('init-dynamic-dirname.ts')) {
break;
}
}
}
return callerFile ? path.dirname(callerFile) : '';
},
});

View File

@@ -0,0 +1,175 @@
import './init-dynamic-dirname';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken';
import { LogWrapper } from '@/common/log';
import { WebSocket, WebSocketServer } from 'ws';
import os from 'os';
import { IPty, spawn as ptySpawn } from '@/pty';
import { randomUUID } from 'crypto';
interface TerminalInstance {
pty: IPty; // 改用 PTY 实例
lastAccess: number;
sockets: Set<WebSocket>;
// 新增标识,用于防止重复关闭
isClosing: boolean;
}
class TerminalManager {
private terminals: Map<string, TerminalInstance> = new Map();
private wss: WebSocketServer | null = null;
initialize(req: any, socket: any, head: any, logger?: LogWrapper) {
logger?.log('[NapCat] [WebUi] terminal websocket initialized');
this.wss = new WebSocketServer({
noServer: true,
verifyClient: async (info, cb) => {
// 验证 token
const url = new URL(info.req.url || '', 'ws://localhost');
const token = url.searchParams.get('token');
const terminalId = url.searchParams.get('id');
if (!token || !terminalId) {
cb(false, 401, 'Unauthorized');
return;
}
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
cb(false, 401, 'Unauthorized');
return;
}
const config = await WebUiConfig.GetWebUIConfig();
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (!validate) {
cb(false, 401, 'Unauthorized');
return;
}
cb(true);
},
});
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss?.emit('connection', ws, req);
});
this.wss.on('connection', async (ws, req) => {
logger?.log('建立终端连接');
try {
const url = new URL(req.url || '', 'ws://localhost');
const terminalId = url.searchParams.get('id')!;
const instance = this.terminals.get(terminalId);
if (!instance) {
ws.close();
return;
}
const dataHandler = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
};
instance.sockets.add(ws);
instance.lastAccess = Date.now();
ws.on('message', (data) => {
if (instance) {
const result = JSON.parse(data.toString());
if (result.type === 'input') {
instance.pty.write(result.data);
}
}
});
ws.on('close', () => {
instance.sockets.delete(ws);
if (instance.sockets.size === 0 && !instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
});
} catch (err) {
console.error('WebSocket authentication failed:', err);
ws.close();
}
});
}
// 修改:移除参数 id使用 crypto.randomUUID 生成终端 id
createTerminal() {
const id = randomUUID();
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const pty = ptySpawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: {
...process.env,
// 统一编码设置
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
TERM: 'xterm-256color',
},
});
const instance: TerminalInstance = {
pty,
lastAccess: Date.now(),
sockets: new Set(),
isClosing: false,
};
pty.onData((data: any) => {
instance.sockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
});
});
pty.onExit(() => {
this.closeTerminal(id);
});
this.terminals.set(id, instance);
// 返回生成的 id 及对应实例,方便后续通知客户端使用该 id
return { id, instance };
}
closeTerminal(id: string) {
const instance = this.terminals.get(id);
if (instance) {
if (!instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
instance.sockets.forEach((ws) => ws.close());
this.terminals.delete(id);
}
}
getTerminal(id: string) {
return this.terminals.get(id);
}
getTerminalList() {
return Array.from(this.terminals.keys()).map((id) => ({
id,
lastAccess: this.terminals.get(id)!.lastAccess,
}));
}
}
export const terminalManager = new TerminalManager();