feat: 系统终端

This commit is contained in:
bietiaop
2025-02-01 20:35:01 +08:00
parent 5120786708
commit 4157746478
15 changed files with 349 additions and 259 deletions

View File

@@ -4,7 +4,7 @@
"version": "0.0.6", "version": "0.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host=0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview" "preview": "vite preview"

View File

@@ -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<number>(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 (
<Tab
ref={setNodeRef}
style={style}
value={value}
{...attributes}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
className={clsx(
'cursor-move select-none border-b-2 transition-colors',
isDragging
? 'bg-default-100 border-primary'
: 'hover:bg-default-100 border-transparent',
className
)}
>
{children}
</Tab>
)
}

View File

@@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { type ReactNode, createContext, forwardRef, useContext } from 'react' import { type ReactNode, createContext, forwardRef, useContext } from 'react'
interface TabsContextValue { export interface TabsContextValue {
activeKey: string activeKey: string
onChange: (key: string) => void onChange: (key: string) => void
} }
@@ -11,7 +11,7 @@ const TabsContext = createContext<TabsContextValue>({
onChange: () => {} onChange: () => {}
}) })
interface TabsProps { export interface TabsProps {
activeKey: string activeKey: string
onChange: (key: string) => void onChange: (key: string) => void
children: ReactNode children: ReactNode
@@ -26,7 +26,7 @@ export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
) )
} }
interface TabListProps { export interface TabListProps {
children: ReactNode children: ReactNode
className?: string className?: string
} }
@@ -37,38 +37,44 @@ export function TabList({ children, className }: TabListProps) {
) )
} }
interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
value: string value: string
className?: string className?: string
children: ReactNode children: ReactNode
isSelected?: boolean
} }
export const Tab = forwardRef<HTMLButtonElement, TabProps>( export const Tab = forwardRef<HTMLDivElement, TabProps>(
({ value, className, children, ...props }, ref) => { ({ className, isSelected, value, ...props }, ref) => {
const { activeKey, onChange } = useContext(TabsContext) const { onChange } = useContext(TabsContext)
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onChange(value)
props.onClick?.(e)
}
return ( return (
<button <div
ref={ref} ref={ref}
onClick={() => onChange(value)} role="tab"
aria-selected={isSelected}
onClick={handleClick}
className={clsx( className={clsx(
'px-4 py-2 rounded-t transition-colors', 'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
activeKey === value isSelected
? 'bg-primary text-white' ? 'border-danger text-danger'
: 'hover:bg-default-100', : 'border-transparent hover:border-default',
className className
)} )}
{...props} {...props}
> />
{children}
</button>
) )
} }
) )
Tab.displayName = 'Tab' Tab.displayName = 'Tab'
interface TabPanelProps { export interface TabPanelProps {
value: string value: string
children: ReactNode children: ReactNode
className?: string className?: string

View File

@@ -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 (
<Tab
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...props}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import WebUIManager from '@/controllers/webui_manager' import TerminalManager from '@/controllers/terminal_manager'
import XTerm, { XTermRef } from '../xterm' import XTerm, { XTermRef } from '../xterm'
@@ -10,48 +10,29 @@ interface TerminalInstanceProps {
export function TerminalInstance({ id }: TerminalInstanceProps) { export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null) const termRef = useRef<XTermRef>(null)
const wsRef = useRef<WebSocket>(null)
useEffect(() => { useEffect(() => {
const ws = WebUIManager.connectTerminal(id, (data) => { const handleData = (data: string) => {
termRef.current?.write(data) try {
}) const parsed = JSON.parse(data)
wsRef.current = ws if (parsed.data) {
termRef.current?.write(parsed.data)
// 添加连接状态监听 }
ws.onopen = () => { } catch (e) {
console.log('Terminal connected:', id) termRef.current?.write(data)
}
} }
ws.onerror = (error) => { TerminalManager.connectTerminal(id, handleData)
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')
}
return () => { return () => {
ws.close() TerminalManager.disconnectTerminal(id, handleData)
} }
}, [id]) }, [id])
const handleInput = (data: string) => { const handleInput = (data: string) => {
const ws = wsRef.current TerminalManager.sendInput(id, data)
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')
}
} }
return <XTerm ref={termRef} onInput={handleInput} className="h-full" /> return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
} }

View File

@@ -25,12 +25,13 @@ export type XTermRef = {
export interface XTermProps export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
onInput?: (data: string) => void onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void
} }
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => { const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null) const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null) const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, ...rest } = props const { className, onInput, onKey, ...rest } = props
const { theme } = useTheme() const { theme } = useTheme()
useEffect(() => { useEffect(() => {
if (!domRef.current) { if (!domRef.current) {
@@ -40,9 +41,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
allowTransparency: true, allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline', cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false, drawBoldTextInBrightColors: false
letterSpacing: 0,
lineHeight: 1.0
}) })
terminalRef.current = terminal terminalRef.current = terminal
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
@@ -74,6 +73,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
} }
}) })
terminal.onKey((event) => {
if (onKey) {
onKey(event.key, event.domEvent)
}
})
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
fitAddon.fit() fitAddon.fit()
}) })

View File

@@ -0,0 +1,88 @@
type TerminalCallback = (data: string) => void
interface TerminalConnection {
ws: WebSocket
callbacks: Set<TerminalCallback>
isConnected: boolean
buffer: string[] // 添加缓存数组
}
class TerminalManager {
private connections: Map<string, TerminalConnection> = 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

View File

@@ -150,41 +150,6 @@ export default class WebUIManager {
return data.data return data.data
} }
public static async sendTerminalInput(
id: string,
input: string
): Promise<void> {
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<void> { public static async closeTerminal(id: string): Promise<void> {
await serverRequest.post(`/Log/terminal/${id}/close`) await serverRequest.post(`/Log/terminal/${id}/close`)
} }
@@ -205,9 +170,15 @@ export default class WebUIManager {
if (!token) throw new Error('未登录') if (!token) throw new Error('未登录')
const _token = JSON.parse(token) 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) => { ws.onmessage = (event) => {
try { try {

View File

@@ -1,4 +1,11 @@
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core' import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors
} from '@dnd-kit/core'
import { import {
SortableContext, SortableContext,
arrayMove, arrayMove,
@@ -9,10 +16,11 @@ import { useEffect, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { IoAdd, IoClose } from 'react-icons/io5' import { IoAdd, IoClose } from 'react-icons/io5'
import { SortableTab } from '@/components/sortable_tab'
import { TabList, TabPanel, Tabs } from '@/components/tabs' import { TabList, TabPanel, Tabs } from '@/components/tabs'
import { SortableTab } from '@/components/tabs/sortable_tab.tsx'
import { TerminalInstance } from '@/components/terminal/terminal-instance' import { TerminalInstance } from '@/components/terminal/terminal-instance'
import terminalManager from '@/controllers/terminal_manager'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
interface TerminalTab { interface TerminalTab {
@@ -29,9 +37,9 @@ export default function TerminalPage() {
WebUIManager.getTerminalList().then((terminals) => { WebUIManager.getTerminalList().then((terminals) => {
if (terminals.length === 0) return if (terminals.length === 0) return
const newTabs = terminals.map((terminal, index) => ({ const newTabs = terminals.map((terminal) => ({
id: terminal.id, id: terminal.id,
title: `Terminal ${index + 1}` title: terminal.id
})) }))
setTabs(newTabs) setTabs(newTabs)
@@ -44,7 +52,7 @@ export default function TerminalPage() {
const { id } = await WebUIManager.createTerminal(80, 24) const { id } = await WebUIManager.createTerminal(80, 24)
const newTab = { const newTab = {
id, id,
title: `Terminal ${tabs.length + 1}` title: id
} }
setTabs((prev) => [...prev, newTab]) setTabs((prev) => [...prev, newTab])
@@ -58,10 +66,16 @@ export default function TerminalPage() {
const closeTerminal = async (id: string) => { const closeTerminal = async (id: string) => {
try { try {
await WebUIManager.closeTerminal(id) await WebUIManager.closeTerminal(id)
setTabs((prev) => prev.filter((tab) => tab.id !== id)) terminalManager.removeTerminal(id)
if (selectedTab === 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) { } catch (error) {
toast.error('关闭终端失败') toast.error('关闭终端失败')
} }
@@ -78,25 +92,49 @@ export default function TerminalPage() {
} }
} }
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
})
)
return ( return (
<div className="flex flex-col h-full gap-2 p-4"> <div className="flex flex-col gap-2 p-4">
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext
<Tabs activeKey={selectedTab} onChange={setSelectedTab}> sensors={sensors}
<div className="flex items-center gap-2"> collisionDetection={closestCenter}
<TabList className="flex-1"> onDragEnd={handleDragEnd}
>
<Tabs
activeKey={selectedTab}
onChange={setSelectedTab}
className="h-full overflow-hidden"
>
<div className="flex items-center gap-2 flex-shrink-0 flex-grow-0">
<TabList className="flex-1 !overflow-x-auto hide-scrollbar">
<SortableContext <SortableContext
items={tabs} items={tabs}
strategy={horizontalListSortingStrategy} strategy={horizontalListSortingStrategy}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<SortableTab key={tab.id} id={tab.id} value={tab.id}> <SortableTab
key={tab.id}
id={tab.id}
value={tab.id}
isSelected={selectedTab === tab.id}
className="flex gap-2 items-center"
>
{tab.title} {tab.title}
<Button <Button
isIconOnly isIconOnly
radius="full"
variant="flat" variant="flat"
size="sm" size="sm"
className="ml-2" className="min-w-0 w-4 h-4"
onPress={() => closeTerminal(tab.id)} onPress={() => closeTerminal(tab.id)}
color={selectedTab === tab.id ? 'danger' : 'default'}
> >
<IoClose /> <IoClose />
</Button> </Button>
@@ -106,15 +144,21 @@ export default function TerminalPage() {
</TabList> </TabList>
<Button <Button
isIconOnly isIconOnly
color="danger"
size="sm"
variant="flat"
onPress={createNewTerminal} onPress={createNewTerminal}
startContent={<IoAdd />} startContent={<IoAdd />}
className="text-xl"
/> />
</div> </div>
{tabs.map((tab) => ( <div className="flex-grow overflow-hidden">
<TabPanel key={tab.id} value={tab.id} className="flex-1"> {tabs.map((tab) => (
<TerminalInstance id={tab.id} /> <TabPanel key={tab.id} value={tab.id} className="h-full">
</TabPanel> <TerminalInstance id={tab.id} />
))} </TabPanel>
))}
</div>
</Tabs> </Tabs>
</DndContext> </DndContext>
</div> </div>

View File

@@ -35,6 +35,20 @@ body {
.font-noto-serif { .font-noto-serif {
font-family: 'Noto Serif SC', 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 { ::-webkit-scrollbar {

View File

@@ -29,6 +29,11 @@ export default defineConfig(({ mode }) => {
base: '/webui/', base: '/webui/',
server: { server: {
proxy: { proxy: {
'/api/ws/terminal': {
target: backendDebugUrl,
ws: true,
changeOrigin: true
},
'/api': backendDebugUrl '/api': backendDebugUrl
} }
}, },

View File

@@ -17,18 +17,16 @@
"dev:depend": "npm i && cd napcat.webui && npm i" "dev:depend": "npm i && cd napcat.webui && npm i"
}, },
"devDependencies": { "devDependencies": {
"json5": "^2.2.3",
"esbuild": "0.24.0",
"@babel/preset-typescript": "^7.24.7", "@babel/preset-typescript": "^7.24.7",
"@eslint/compat": "^1.2.2", "@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@log4js-node/log4js-api": "^1.0.2", "@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4", "@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-node-resolve": "^16.0.0",
"@types/cors": "^2.8.17", "@rollup/plugin-typescript": "^12.1.2",
"@sinclair/typebox": "^0.34.9", "@sinclair/typebox": "^0.34.9",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.1", "@types/node": "^22.0.1",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
@@ -39,6 +37,7 @@
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"commander": "^13.0.0", "commander": "^13.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"esbuild": "0.24.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
@@ -46,6 +45,7 @@
"file-type": "^20.0.0", "file-type": "^20.0.0",
"globals": "^15.12.0", "globals": "^15.12.0",
"image-size": "^1.1.1", "image-size": "^1.1.1",
"json5": "^2.2.3",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.13.0", "typescript-eslint": "^8.13.0",
"vite": "^6.0.1", "vite": "^6.0.1",
@@ -57,6 +57,7 @@
"@ffmpeg.wasm/core-mt": "^0.13.2", "@ffmpeg.wasm/core-mt": "^0.13.2",
"@ffmpeg.wasm/main": "^0.13.1", "@ffmpeg.wasm/main": "^0.13.1",
"express": "^5.0.0", "express": "^5.0.0",
"node-pty": "^1.1.0-beta22",
"piscina": "^4.7.0", "piscina": "^4.7.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"silk-wasm": "^3.6.1", "silk-wasm": "^3.6.1",

View File

@@ -3,6 +3,7 @@
*/ */
import express from 'express'; import express from 'express';
import { createServer } from 'http';
import { LogWrapper } from '@/common/log'; import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path'; import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config'; import { WebUiConfigWrapper } from '@webapi/helper/config';
@@ -15,7 +16,7 @@ import { terminalManager } from '@webapi/terminal/terminal_manager';
// 实例化Express // 实例化Express
const app = express(); const app = express();
const server = createServer(app);
/** /**
* 初始化并启动WebUI服务。 * 初始化并启动WebUI服务。
* 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。 * 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。
@@ -47,7 +48,9 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// 挂载静态路由(前端),路径为 [/前缀]/webui // 挂载静态路由(前端),路径为 [/前缀]/webui
app.use('/webui', express.static(pathWrapper.staticPath)); app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器 // 初始化WebSocket服务器
terminalManager.initialize(app); server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
});
// 挂载API接口 // 挂载API接口
app.use('/api', ALLRouter); 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(), const port = config.port.toString(),
searchParams = { token: config.token }; searchParams = { token: config.token };

View File

@@ -1,54 +1,64 @@
import { WebUiConfig } from '@/webui'; import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken'; import { AuthHelper } from '../helper/SignToken';
import { spawn, type ChildProcess } from 'child_process'; import { LogWrapper } from '@/common/log';
import * as os from 'os';
import { WebSocket, WebSocketServer } from 'ws'; 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 { interface TerminalInstance {
process: ChildProcess; pty: IPty; // 改用 PTY 实例
lastAccess: number; lastAccess: number;
dataHandlers: Set<(data: string) => void>; sockets: Set<WebSocket>;
} }
class TerminalManager { class TerminalManager {
private terminals: Map<string, TerminalInstance> = new Map(); private terminals: Map<string, TerminalInstance> = new Map();
private wss: WebSocketServer | null = null; 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({ this.wss = new WebSocketServer({
server, noServer: true,
path: '/api/ws/terminal', verifyClient: async (info, cb) => {
}); // 验证 token
const url = new URL(info.req.url || '', 'ws://localhost');
this.wss.on('connection', async (ws, req) => {
try {
const url = new URL(req.url || '', 'ws://localhost');
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
const terminalId = url.searchParams.get('id'); const terminalId = url.searchParams.get('id');
if (!token || !terminalId) { if (!token || !terminalId) {
ws.close(); cb(false, 401, 'Unauthorized');
return; return;
} }
// 验证 token
// 解析token // 解析token
let Credential: WebUiCredentialJson; let Credential: WebUiCredentialJson;
try { try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) { } catch (e) {
ws.close(); cb(false, 401, 'Unauthorized');
return; return;
} }
const config = await WebUiConfig.GetWebUIConfig(); const config = await WebUiConfig.GetWebUIConfig();
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (!validate) { if (!validate) {
ws.close(); cb(false, 401, 'Unauthorized');
return; 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); const instance = this.terminals.get(terminalId);
if (!instance) { if (!instance) {
ws.close(); ws.close();
return; return;
@@ -59,21 +69,24 @@ class TerminalManager {
ws.send(JSON.stringify({ type: 'output', data })); ws.send(JSON.stringify({ type: 'output', data }));
} }
}; };
instance.dataHandlers.add(dataHandler);
ws.on('message', (message) => { instance.sockets.add(ws);
try { instance.lastAccess = Date.now();
const data = JSON.parse(message.toString());
if (data.type === 'input') { ws.on('message', (data) => {
this.writeTerminal(terminalId, data.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', () => { ws.on('close', () => {
instance.dataHandlers.delete(dataHandler); instance.sockets.delete(ws);
if (instance.sockets.size === 0) {
instance.pty.kill();
}
}); });
} catch (err) { } catch (err) {
console.error('WebSocket authentication failed:', err); console.error('WebSocket authentication failed:', err);
@@ -84,64 +97,52 @@ class TerminalManager {
createTerminal(id: string) { createTerminal(id: string) {
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellProcess = spawn(shell, [], { const pty = ptySpawn(shell, [], {
env: process.env, name: 'xterm-256color',
shell: true, 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 = { const instance: TerminalInstance = {
process: shellProcess, pty,
lastAccess: Date.now(), lastAccess: Date.now(),
dataHandlers: new Set(), sockets: new Set(),
}; };
// 修改这里,使用 shellProcess 而不是 process pty.onData((data: any) => {
shellProcess.stdout.on('data', (data) => { instance.sockets.forEach((ws) => {
const str = data.toString(); if (ws.readyState === WebSocket.OPEN) {
instance.dataHandlers.forEach((handler) => handler(str)); ws.send(JSON.stringify({ type: 'output', data }));
}
});
}); });
shellProcess.stderr.on('data', (data) => { pty.onExit(() => {
const str = data.toString(); this.closeTerminal(id);
instance.dataHandlers.forEach((handler) => handler(str));
}); });
this.terminals.set(id, instance); this.terminals.set(id, instance);
return instance; return instance;
} }
getTerminal(id: string) {
return this.terminals.get(id);
}
closeTerminal(id: string) { closeTerminal(id: string) {
const instance = this.terminals.get(id); const instance = this.terminals.get(id);
if (instance) { if (instance) {
instance.process.kill(); instance.pty.kill();
instance.sockets.forEach((ws) => ws.close());
this.terminals.delete(id); this.terminals.delete(id);
} }
} }
onTerminalData(id: string, handler: (data: string) => void) { getTerminal(id: string) {
const instance = this.terminals.get(id); return 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);
}
});
}
} }
getTerminalList() { getTerminalList() {

View File

@@ -4,7 +4,16 @@ import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module'; 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(); const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
let startScripts: string[] | undefined = undefined; let startScripts: string[] | undefined = undefined;
@@ -56,7 +65,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
nodeResolve(), nodeResolve(),
]; ];
const ShellBaseConfigPlugin: PluginOption[] = [ const ShellBaseConfigPlugin: PluginOption[] = [
cp({ cp({
targets: [ targets: [
@@ -101,7 +109,6 @@ const UniversalBaseConfig = () =>
}, },
}); });
const ShellBaseConfig = () => const ShellBaseConfig = () =>
defineConfig({ defineConfig({
resolve: { resolve: {