diff --git a/napcat.webui/package.json b/napcat.webui/package.json index 320d297b..07246511 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -4,7 +4,7 @@ "version": "0.0.6", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host=0.0.0.0", "build": "tsc && vite build", "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "preview": "vite preview" diff --git a/napcat.webui/src/components/sortable_tab.tsx b/napcat.webui/src/components/sortable_tab.tsx deleted file mode 100644 index e90398b0..00000000 --- a/napcat.webui/src/components/sortable_tab.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useSortable } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import clsx from 'clsx' -import { useRef } from 'react' - -import { Tab } from './tabs' - -interface SortableTabProps { - id: string - value: string - children: React.ReactNode - className?: string -} - -export function SortableTab({ - id, - value, - children, - className -}: SortableTabProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging - } = useSortable({ id }) - - const mouseDownTime = useRef(0) - const mouseDownPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 1 : 0 - } - - const handleMouseDown = (e: React.MouseEvent) => { - mouseDownTime.current = Date.now() - mouseDownPos.current = { x: e.clientX, y: e.clientY } - } - - const handleMouseUp = (e: React.MouseEvent) => { - const timeDiff = Date.now() - mouseDownTime.current - const distanceX = Math.abs(e.clientX - mouseDownPos.current.x) - const distanceY = Math.abs(e.clientY - mouseDownPos.current.y) - - // 如果时间小于200ms且移动距离小于5px,认为是点击而不是拖拽 - if (timeDiff < 200 && distanceX < 5 && distanceY < 5) { - listeners?.onClick?.(e) - } - } - - return ( - - {children} - - ) -} diff --git a/napcat.webui/src/components/tabs/index.tsx b/napcat.webui/src/components/tabs/index.tsx index 4b623af6..9a9afc32 100644 --- a/napcat.webui/src/components/tabs/index.tsx +++ b/napcat.webui/src/components/tabs/index.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { type ReactNode, createContext, forwardRef, useContext } from 'react' -interface TabsContextValue { +export interface TabsContextValue { activeKey: string onChange: (key: string) => void } @@ -11,7 +11,7 @@ const TabsContext = createContext({ onChange: () => {} }) -interface TabsProps { +export interface TabsProps { activeKey: string onChange: (key: string) => void children: ReactNode @@ -26,7 +26,7 @@ export function Tabs({ activeKey, onChange, children, className }: TabsProps) { ) } -interface TabListProps { +export interface TabListProps { children: ReactNode className?: string } @@ -37,38 +37,44 @@ export function TabList({ children, className }: TabListProps) { ) } -interface TabProps extends React.ButtonHTMLAttributes { +export interface TabProps extends React.ButtonHTMLAttributes { value: string className?: string children: ReactNode + isSelected?: boolean } -export const Tab = forwardRef( - ({ value, className, children, ...props }, ref) => { - const { activeKey, onChange } = useContext(TabsContext) +export const Tab = forwardRef( + ({ className, isSelected, value, ...props }, ref) => { + const { onChange } = useContext(TabsContext) + + const handleClick = (e: React.MouseEvent) => { + onChange(value) + props.onClick?.(e) + } return ( - + /> ) } ) Tab.displayName = 'Tab' -interface TabPanelProps { +export interface TabPanelProps { value: string children: ReactNode className?: string diff --git a/napcat.webui/src/components/tabs/sortable_tab.tsx b/napcat.webui/src/components/tabs/sortable_tab.tsx new file mode 100644 index 00000000..a5713a23 --- /dev/null +++ b/napcat.webui/src/components/tabs/sortable_tab.tsx @@ -0,0 +1,38 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +import { Tab } from '@/components/tabs' +import type { TabProps } from '@/components/tabs' + +interface SortableTabProps extends TabProps { + id: string +} + +export function SortableTab({ id, ...props }: SortableTabProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0, + position: 'relative' as const, + touchAction: 'none' + } + + return ( + + ) +} diff --git a/napcat.webui/src/components/terminal/terminal-instance.tsx b/napcat.webui/src/components/terminal/terminal-instance.tsx index 6cdf14f8..1b21e382 100644 --- a/napcat.webui/src/components/terminal/terminal-instance.tsx +++ b/napcat.webui/src/components/terminal/terminal-instance.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import WebUIManager from '@/controllers/webui_manager' +import TerminalManager from '@/controllers/terminal_manager' import XTerm, { XTermRef } from '../xterm' @@ -10,48 +10,29 @@ interface TerminalInstanceProps { export function TerminalInstance({ id }: TerminalInstanceProps) { const termRef = useRef(null) - const wsRef = useRef(null) useEffect(() => { - const ws = WebUIManager.connectTerminal(id, (data) => { - termRef.current?.write(data) - }) - wsRef.current = ws - - // 添加连接状态监听 - ws.onopen = () => { - console.log('Terminal connected:', id) + const handleData = (data: string) => { + try { + const parsed = JSON.parse(data) + if (parsed.data) { + termRef.current?.write(parsed.data) + } + } catch (e) { + termRef.current?.write(data) + } } - ws.onerror = (error) => { - console.error('Terminal connection error:', error) - termRef.current?.write( - '\r\n\x1b[31mConnection error. Please try reconnecting.\x1b[0m\r\n' - ) - } - - ws.onclose = () => { - console.log('Terminal disconnected:', id) - termRef.current?.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n') - } + TerminalManager.connectTerminal(id, handleData) return () => { - ws.close() + TerminalManager.disconnectTerminal(id, handleData) } }, [id]) const handleInput = (data: string) => { - const ws = wsRef.current - if (ws?.readyState === WebSocket.OPEN) { - try { - ws.send(JSON.stringify({ type: 'input', data })) - } catch (error) { - console.error('Failed to send terminal input:', error) - } - } else { - console.warn('WebSocket is not in OPEN state') - } + TerminalManager.sendInput(id, data) } - return + return } diff --git a/napcat.webui/src/components/xterm.tsx b/napcat.webui/src/components/xterm.tsx index 506187b6..f9ee8a1a 100644 --- a/napcat.webui/src/components/xterm.tsx +++ b/napcat.webui/src/components/xterm.tsx @@ -25,12 +25,13 @@ export type XTermRef = { export interface XTermProps extends Omit, 'onInput'> { onInput?: (data: string) => void + onKey?: (key: string, event: KeyboardEvent) => void } const XTerm = forwardRef((props, ref) => { const domRef = useRef(null) const terminalRef = useRef(null) - const { className, onInput, ...rest } = props + const { className, onInput, onKey, ...rest } = props const { theme } = useTheme() useEffect(() => { if (!domRef.current) { @@ -40,9 +41,7 @@ const XTerm = forwardRef((props, ref) => { allowTransparency: true, fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', cursorInactiveStyle: 'outline', - drawBoldTextInBrightColors: false, - letterSpacing: 0, - lineHeight: 1.0 + drawBoldTextInBrightColors: false }) terminalRef.current = terminal const fitAddon = new FitAddon() @@ -74,6 +73,12 @@ const XTerm = forwardRef((props, ref) => { } }) + terminal.onKey((event) => { + if (onKey) { + onKey(event.key, event.domEvent) + } + }) + const resizeObserver = new ResizeObserver(() => { fitAddon.fit() }) diff --git a/napcat.webui/src/controllers/terminal_manager.ts b/napcat.webui/src/controllers/terminal_manager.ts new file mode 100644 index 00000000..f517441a --- /dev/null +++ b/napcat.webui/src/controllers/terminal_manager.ts @@ -0,0 +1,88 @@ +type TerminalCallback = (data: string) => void + +interface TerminalConnection { + ws: WebSocket + callbacks: Set + isConnected: boolean + buffer: string[] // 添加缓存数组 +} + +class TerminalManager { + private connections: Map = new Map() + private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小 + + connectTerminal(id: string, callback: TerminalCallback): WebSocket { + let conn = this.connections.get(id) + + if (!conn) { + const url = new URL(window.location.href) + url.protocol = url.protocol.replace('http', 'ws') + url.pathname = `/api/ws/terminal` + url.searchParams.set('id', id) + const token = JSON.parse(localStorage.getItem('token') || '') + if (!token) { + throw new Error('No token found') + } + url.searchParams.set('token', token) + const ws = new WebSocket(url.toString()) + conn = { + ws, + callbacks: new Set([callback]), + isConnected: false, + buffer: [] // 初始化缓存 + } + + ws.onmessage = (event) => { + const data = event.data + // 保存到缓存 + conn?.buffer.push(data) + if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) { + conn?.buffer.shift() + } + conn?.callbacks.forEach((cb) => cb(data)) + } + + ws.onopen = () => { + if (conn) conn.isConnected = true + } + + ws.onclose = () => { + if (conn) conn.isConnected = false + } + + this.connections.set(id, conn) + } else { + conn.callbacks.add(callback) + // 恢复历史内容 + conn.buffer.forEach((data) => callback(data)) + } + + return conn.ws + } + + disconnectTerminal(id: string, callback: TerminalCallback) { + const conn = this.connections.get(id) + if (!conn) return + + conn.callbacks.delete(callback) + } + + removeTerminal(id: string) { + const conn = this.connections.get(id) + if (conn?.ws.readyState === WebSocket.OPEN) { + conn.ws.close() + } + this.connections.delete(id) + } + + sendInput(id: string, data: string) { + const conn = this.connections.get(id) + if (conn?.ws.readyState === WebSocket.OPEN) { + conn.ws.send(JSON.stringify({ type: 'input', data })) + } + } +} + +const terminalManager = new TerminalManager() + +export default terminalManager diff --git a/napcat.webui/src/controllers/webui_manager.ts b/napcat.webui/src/controllers/webui_manager.ts index 5f682118..701030c1 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -150,41 +150,6 @@ export default class WebUIManager { return data.data } - public static async sendTerminalInput( - id: string, - input: string - ): Promise { - await serverRequest.post(`/Log/terminal/${id}/input`, { input }) - } - - public static getTerminalStream(id: string, onData: (data: string) => void) { - const token = localStorage.getItem('token') - if (!token) throw new Error('未登录') - - const _token = JSON.parse(token) - const eventSource = new EventSourcePolyfill( - `/api/Log/terminal/${id}/stream`, - { - headers: { - Authorization: `Bearer ${_token}`, - Accept: 'text/event-stream' - }, - withCredentials: true - } - ) - - eventSource.onmessage = (event) => { - try { - const { data } = JSON.parse(event.data) - onData(data) - } catch (error) { - console.error(error) - } - } - - return eventSource - } - public static async closeTerminal(id: string): Promise { await serverRequest.post(`/Log/terminal/${id}/close`) } @@ -205,9 +170,15 @@ export default class WebUIManager { if (!token) throw new Error('未登录') const _token = JSON.parse(token) - const ws = new WebSocket( - `ws://${window.location.host}/api/ws/terminal?id=${id}&token=${_token}` - ) + + const url = new URL(window.location.origin) + url.protocol = "ws://" + url.pathname = "/api/ws/terminal" + url.searchParams.set('token', _token) + url.searchParams.set("id", id) + console.log(url.toString()) + + const ws = new WebSocket(url.toString()) ws.onmessage = (event) => { try { diff --git a/napcat.webui/src/pages/dashboard/terminal.tsx b/napcat.webui/src/pages/dashboard/terminal.tsx index 610b1bc2..4df26589 100644 --- a/napcat.webui/src/pages/dashboard/terminal.tsx +++ b/napcat.webui/src/pages/dashboard/terminal.tsx @@ -1,4 +1,11 @@ -import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core' +import { + DndContext, + DragEndEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors +} from '@dnd-kit/core' import { SortableContext, arrayMove, @@ -9,10 +16,11 @@ import { useEffect, useState } from 'react' import toast from 'react-hot-toast' import { IoAdd, IoClose } from 'react-icons/io5' -import { SortableTab } from '@/components/sortable_tab' import { TabList, TabPanel, Tabs } from '@/components/tabs' +import { SortableTab } from '@/components/tabs/sortable_tab.tsx' import { TerminalInstance } from '@/components/terminal/terminal-instance' +import terminalManager from '@/controllers/terminal_manager' import WebUIManager from '@/controllers/webui_manager' interface TerminalTab { @@ -29,9 +37,9 @@ export default function TerminalPage() { WebUIManager.getTerminalList().then((terminals) => { if (terminals.length === 0) return - const newTabs = terminals.map((terminal, index) => ({ + const newTabs = terminals.map((terminal) => ({ id: terminal.id, - title: `Terminal ${index + 1}` + title: terminal.id })) setTabs(newTabs) @@ -44,7 +52,7 @@ export default function TerminalPage() { const { id } = await WebUIManager.createTerminal(80, 24) const newTab = { id, - title: `Terminal ${tabs.length + 1}` + title: id } setTabs((prev) => [...prev, newTab]) @@ -58,10 +66,16 @@ export default function TerminalPage() { const closeTerminal = async (id: string) => { try { await WebUIManager.closeTerminal(id) - setTabs((prev) => prev.filter((tab) => tab.id !== id)) + terminalManager.removeTerminal(id) if (selectedTab === id) { - setSelectedTab(tabs[0]?.id || '') + const previousIndex = tabs.findIndex((tab) => tab.id === id) - 1 + if (previousIndex >= 0) { + setSelectedTab(tabs[previousIndex].id) + } else { + setSelectedTab(tabs[0]?.id || '') + } } + setTabs((prev) => prev.filter((tab) => tab.id !== id)) } catch (error) { toast.error('关闭终端失败') } @@ -78,25 +92,49 @@ export default function TerminalPage() { } } + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8 + } + }) + ) + return ( -
- - -
- +
+ + +
+ {tabs.map((tab) => ( - + {tab.title} @@ -106,15 +144,21 @@ export default function TerminalPage() {
- {tabs.map((tab) => ( - - - - ))} +
+ {tabs.map((tab) => ( + + + + ))} +
diff --git a/napcat.webui/src/styles/globals.css b/napcat.webui/src/styles/globals.css index bbbcae09..5acca3ef 100644 --- a/napcat.webui/src/styles/globals.css +++ b/napcat.webui/src/styles/globals.css @@ -35,6 +35,20 @@ body { .font-noto-serif { font-family: 'Noto Serif SC', serif; } + .hide-scrollbar::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + } + .hide-scrollbar::-webkit-scrollbar-thumb { + width: 0 !important; + height: 0 !important; + background-color: transparent !important; + } + .hide-scrollbar::-webkit-scrollbar-track { + width: 0 !important; + height: 0 !important; + background-color: transparent !important; + } } ::-webkit-scrollbar { diff --git a/napcat.webui/vite.config.ts b/napcat.webui/vite.config.ts index f36c3dbc..043e0bfa 100644 --- a/napcat.webui/vite.config.ts +++ b/napcat.webui/vite.config.ts @@ -29,6 +29,11 @@ export default defineConfig(({ mode }) => { base: '/webui/', server: { proxy: { + '/api/ws/terminal': { + target: backendDebugUrl, + ws: true, + changeOrigin: true + }, '/api': backendDebugUrl } }, diff --git a/package.json b/package.json index 8345ac89..442c6233 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,16 @@ "dev:depend": "npm i && cd napcat.webui && npm i" }, "devDependencies": { - "json5": "^2.2.3", - "esbuild": "0.24.0", "@babel/preset-typescript": "^7.24.7", "@eslint/compat": "^1.2.2", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.14.0", "@log4js-node/log4js-api": "^1.0.2", "@napneko/nap-proto-core": "^0.0.4", - "@rollup/plugin-typescript": "^12.1.2", "@rollup/plugin-node-resolve": "^16.0.0", - "@types/cors": "^2.8.17", + "@rollup/plugin-typescript": "^12.1.2", "@sinclair/typebox": "^0.34.9", + "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.0.1", "@types/qrcode-terminal": "^0.12.2", @@ -39,6 +37,7 @@ "async-mutex": "^0.5.0", "commander": "^13.0.0", "cors": "^2.8.5", + "esbuild": "0.24.0", "eslint": "^9.14.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.1", @@ -46,6 +45,7 @@ "file-type": "^20.0.0", "globals": "^15.12.0", "image-size": "^1.1.1", + "json5": "^2.2.3", "typescript": "^5.3.3", "typescript-eslint": "^8.13.0", "vite": "^6.0.1", @@ -57,6 +57,7 @@ "@ffmpeg.wasm/core-mt": "^0.13.2", "@ffmpeg.wasm/main": "^0.13.1", "express": "^5.0.0", + "node-pty": "^1.1.0-beta22", "piscina": "^4.7.0", "qrcode-terminal": "^0.12.0", "silk-wasm": "^3.6.1", diff --git a/src/webui/index.ts b/src/webui/index.ts index cf5fa656..d4d9edcd 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -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'; @@ -15,7 +16,7 @@ import { terminalManager } from '@webapi/terminal/terminal_manager'; // 实例化Express const app = express(); - +const server = createServer(app); /** * 初始化并启动WebUI服务。 * 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。 @@ -47,7 +48,9 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // 挂载静态路由(前端),路径为 [/前缀]/webui app.use('/webui', express.static(pathWrapper.staticPath)); // 初始化WebSocket服务器 - terminalManager.initialize(app); + server.on('upgrade', (request, socket, head) => { + terminalManager.initialize(request, socket, head, logger); + }); // 挂载API接口 app.use('/api', ALLRouter); // 所有剩下的请求都转到静态页面 @@ -64,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 }; diff --git a/src/webui/src/terminal/terminal_manager.ts b/src/webui/src/terminal/terminal_manager.ts index 671c7552..e3cd66dc 100644 --- a/src/webui/src/terminal/terminal_manager.ts +++ b/src/webui/src/terminal/terminal_manager.ts @@ -1,54 +1,64 @@ import { WebUiConfig } from '@/webui'; import { AuthHelper } from '../helper/SignToken'; -import { spawn, type ChildProcess } from 'child_process'; -import * as os from 'os'; +import { LogWrapper } from '@/common/log'; import { WebSocket, WebSocketServer } from 'ws'; +import os from 'os'; +import { spawn, ChildProcess } from 'child_process'; +import { IPty, spawn as ptySpawn } from 'node-pty'; interface TerminalInstance { - process: ChildProcess; + pty: IPty; // 改用 PTY 实例 lastAccess: number; - dataHandlers: Set<(data: string) => void>; + sockets: Set; } class TerminalManager { private terminals: Map = new Map(); private wss: WebSocketServer | null = null; - initialize(server: any) { + initialize(req: any, socket: any, head: any, logger?: LogWrapper) { + logger?.log('[NapCat] [WebUi] terminal websocket initialized'); this.wss = new WebSocketServer({ - server, - path: '/api/ws/terminal', - }); - - this.wss.on('connection', async (ws, req) => { - try { - const url = new URL(req.url || '', 'ws://localhost'); + 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) { - ws.close(); + cb(false, 401, 'Unauthorized'); return; } - // 验证 token // 解析token let Credential: WebUiCredentialJson; try { Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); } catch (e) { - ws.close(); + cb(false, 401, 'Unauthorized'); return; } const config = await WebUiConfig.GetWebUIConfig(); const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); - if (!validate) { - ws.close(); + 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; @@ -59,21 +69,24 @@ class TerminalManager { ws.send(JSON.stringify({ type: 'output', data })); } }; - instance.dataHandlers.add(dataHandler); - ws.on('message', (message) => { - try { - const data = JSON.parse(message.toString()); - if (data.type === 'input') { - this.writeTerminal(terminalId, data.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); } - } catch (error) { - console.error('Failed to process terminal input:', error); } }); ws.on('close', () => { - instance.dataHandlers.delete(dataHandler); + instance.sockets.delete(ws); + if (instance.sockets.size === 0) { + instance.pty.kill(); + } }); } catch (err) { console.error('WebSocket authentication failed:', err); @@ -84,64 +97,52 @@ class TerminalManager { createTerminal(id: string) { const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; - const shellProcess = spawn(shell, [], { - env: process.env, - shell: true, + 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 = { - process: shellProcess, + pty, lastAccess: Date.now(), - dataHandlers: new Set(), + sockets: new Set(), }; - // 修改这里,使用 shellProcess 而不是 process - shellProcess.stdout.on('data', (data) => { - const str = data.toString(); - instance.dataHandlers.forEach((handler) => handler(str)); + pty.onData((data: any) => { + instance.sockets.forEach((ws) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data })); + } + }); }); - shellProcess.stderr.on('data', (data) => { - const str = data.toString(); - instance.dataHandlers.forEach((handler) => handler(str)); + pty.onExit(() => { + this.closeTerminal(id); }); this.terminals.set(id, instance); return instance; } - getTerminal(id: string) { - return this.terminals.get(id); - } - closeTerminal(id: string) { const instance = this.terminals.get(id); if (instance) { - instance.process.kill(); + instance.pty.kill(); + instance.sockets.forEach((ws) => ws.close()); this.terminals.delete(id); } } - onTerminalData(id: string, handler: (data: string) => void) { - const instance = this.terminals.get(id); - if (instance) { - instance.dataHandlers.add(handler); - return () => { - instance.dataHandlers.delete(handler); - }; - } - return () => {}; - } - - writeTerminal(id: string, data: string) { - const instance = this.terminals.get(id); - if (instance && instance.process.stdin) { - instance.process.stdin.write(data, (error) => { - if (error) { - console.error('Failed to write to terminal:', error); - } - }); - } + getTerminal(id: string) { + return this.terminals.get(id); } getTerminalList() { diff --git a/vite.config.ts b/vite.config.ts index 151f3c14..aa54a330 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,16 @@ import { resolve } from 'path'; import nodeResolve from '@rollup/plugin-node-resolve'; import { builtinModules } from 'module'; //依赖排除 -const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'piscina', '@ffmpeg.wasm/core-mt', "@ffmpeg.wasm/main"]; +const external = [ + 'silk-wasm', + 'ws', + 'express', + 'qrcode-terminal', + 'piscina', + '@ffmpeg.wasm/core-mt', + '@ffmpeg.wasm/main', + 'node-pty', +]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); let startScripts: string[] | undefined = undefined; @@ -56,7 +65,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [ nodeResolve(), ]; - const ShellBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ @@ -101,7 +109,6 @@ const UniversalBaseConfig = () => }, }); - const ShellBaseConfig = () => defineConfig({ resolve: {