diff --git a/napcat.webui/package.json b/napcat.webui/package.json index 0d1d8476..0ef7e2db 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -4,12 +4,15 @@ "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" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@heroui/avatar": "2.2.7", "@heroui/breadcrumbs": "2.2.7", "@heroui/button": "2.2.10", @@ -33,6 +36,7 @@ "@heroui/spinner": "2.2.7", "@heroui/switch": "2.2.9", "@heroui/system": "2.4.7", + "@heroui/table": "^2.2.9", "@heroui/tabs": "2.2.8", "@heroui/theme": "2.4.6", "@heroui/tooltip": "2.2.8", @@ -53,6 +57,7 @@ "framer-motion": "^12.0.6", "monaco-editor": "^0.52.2", "motion": "^12.0.6", + "path-browserify": "^1.0.1", "qface": "^1.4.1", "qrcode.react": "^4.2.0", "quill": "^2.0.3", @@ -80,6 +85,7 @@ "@types/event-source-polyfill": "^1.0.5", "@types/fabric": "^5.3.9", "@types/node": "^22.12.0", + "@types/path-browserify": "^1.0.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@types/react-window": "^1.8.8", diff --git a/napcat.webui/src/components/file_icon.tsx b/napcat.webui/src/components/file_icon.tsx new file mode 100644 index 00000000..9327abe9 --- /dev/null +++ b/napcat.webui/src/components/file_icon.tsx @@ -0,0 +1,166 @@ +import { + FaFile, + FaFileAudio, + FaFileCode, + FaFileCsv, + FaFileExcel, + FaFileImage, + FaFileLines, + FaFilePdf, + FaFilePowerpoint, + FaFileVideo, + FaFileWord, + FaFileZipper, + FaFolderClosed +} from 'react-icons/fa6' + +export interface FileIconProps { + name?: string + isDirectory?: boolean +} + +const FileIcon = (props: FileIconProps) => { + const { name, isDirectory = false } = props + if (isDirectory) { + return + } + + const ext = name?.split('.').pop() || '' + if (ext) { + switch (ext.toLowerCase()) { + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'svg': + case 'bmp': + case 'ico': + case 'webp': + case 'tiff': + case 'tif': + case 'heic': + case 'heif': + case 'avif': + case 'apng': + case 'flif': + case 'ai': + case 'psd': + case 'xcf': + case 'sketch': + case 'fig': + case 'xd': + case 'svgz': + return + case 'pdf': + return + case 'doc': + case 'docx': + return + case 'xls': + case 'xlsx': + return + case 'csv': + return + case 'ppt': + case 'pptx': + return + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + case 'bz2': + case 'xz': + case 'lz': + case 'lzma': + case 'zst': + case 'zstd': + case 'z': + case 'taz': + case 'tz': + case 'tzo': + return + case 'txt': + return + case 'mp3': + case 'wav': + case 'flac': + return + case 'mp4': + case 'avi': + case 'mov': + case 'wmv': + return + case 'html': + case 'css': + case 'js': + case 'ts': + case 'jsx': + case 'tsx': + case 'json': + case 'xml': + case 'yaml': + case 'yml': + case 'md': + case 'sh': + case 'py': + case 'java': + case 'c': + case 'cpp': + case 'cs': + case 'go': + case 'php': + case 'rb': + case 'pl': + case 'swift': + case 'kt': + case 'rs': + case 'sql': + case 'r': + case 'scala': + case 'groovy': + case 'dart': + case 'lua': + case 'perl': + case 'h': + case 'm': + case 'mm': + case 'makefile': + case 'cmake': + case 'dockerfile': + case 'gradle': + case 'properties': + case 'ini': + case 'conf': + case 'env': + case 'bat': + case 'cmd': + case 'ps1': + case 'psm1': + case 'psd1': + case 'ps1xml': + case 'psc1': + case 'pssc': + case 'nuspec': + case 'resx': + case 'resw': + case 'csproj': + case 'vbproj': + case 'vcxproj': + case 'fsproj': + case 'sln': + case 'suo': + case 'user': + case 'userosscache': + case 'sln.docstates': + case 'dll': + return + default: + return + } + } + + return +} + +export default FileIcon diff --git a/napcat.webui/src/components/file_manage/create_file_modal.tsx b/napcat.webui/src/components/file_manage/create_file_modal.tsx new file mode 100644 index 00000000..b4694f07 --- /dev/null +++ b/napcat.webui/src/components/file_manage/create_file_modal.tsx @@ -0,0 +1,64 @@ +import { Button, ButtonGroup } from '@heroui/button' +import { Input } from '@heroui/input' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +interface CreateFileModalProps { + isOpen: boolean + fileType: 'file' | 'directory' + newFileName: string + onTypeChange: (type: 'file' | 'directory') => void + onNameChange: (e: React.ChangeEvent) => void + onClose: () => void + onCreate: () => void +} + +export default function CreateFileModal({ + isOpen, + fileType, + newFileName, + onTypeChange, + onNameChange, + onClose, + onCreate +}: CreateFileModalProps) { + return ( + + + 新建 + +
+ + + + + +
+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/file_edit_modal.tsx b/napcat.webui/src/components/file_manage/file_edit_modal.tsx new file mode 100644 index 00000000..ca42e09c --- /dev/null +++ b/napcat.webui/src/components/file_manage/file_edit_modal.tsx @@ -0,0 +1,94 @@ +import { Button } from '@heroui/button' +import { Code } from '@heroui/code' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +import CodeEditor from '@/components/code_editor' + +interface FileEditModalProps { + isOpen: boolean + file: { path: string; content: string } | null + onClose: () => void + onSave: () => void + onContentChange: (newContent?: string) => void +} + +export default function FileEditModal({ + isOpen, + file, + onClose, + onSave, + onContentChange +}: FileEditModalProps) { + // 根据文件后缀返回对应语言 + const getLanguage = (filePath: string) => { + if (filePath.endsWith('.js')) return 'javascript' + if (filePath.endsWith('.ts')) return 'typescript' + if (filePath.endsWith('.tsx')) return 'tsx' + if (filePath.endsWith('.jsx')) return 'jsx' + if (filePath.endsWith('.vue')) return 'vue' + if (filePath.endsWith('.svelte')) return 'svelte' + if (filePath.endsWith('.json')) return 'json' + if (filePath.endsWith('.html')) return 'html' + if (filePath.endsWith('.css')) return 'css' + if (filePath.endsWith('.scss')) return 'scss' + if (filePath.endsWith('.less')) return 'less' + if (filePath.endsWith('.md')) return 'markdown' + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml' + if (filePath.endsWith('.xml')) return 'xml' + if (filePath.endsWith('.sql')) return 'sql' + if (filePath.endsWith('.sh')) return 'shell' + if (filePath.endsWith('.bat')) return 'bat' + if (filePath.endsWith('.php')) return 'php' + if (filePath.endsWith('.java')) return 'java' + if (filePath.endsWith('.c')) return 'c' + if (filePath.endsWith('.cpp')) return 'cpp' + if (filePath.endsWith('.h')) return 'h' + if (filePath.endsWith('.hpp')) return 'hpp' + if (filePath.endsWith('.go')) return 'go' + if (filePath.endsWith('.py')) return 'python' + if (filePath.endsWith('.rb')) return 'ruby' + if (filePath.endsWith('.cs')) return 'csharp' + if (filePath.endsWith('.swift')) return 'swift' + if (filePath.endsWith('.vb')) return 'vb' + if (filePath.endsWith('.lua')) return 'lua' + if (filePath.endsWith('.pl')) return 'perl' + if (filePath.endsWith('.r')) return 'r' + return 'plaintext' + } + + return ( + + + + 编辑文件 + {file?.path} + + +
+ +
+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/file_table.tsx b/napcat.webui/src/components/file_manage/file_table.tsx new file mode 100644 index 00000000..22cccb26 --- /dev/null +++ b/napcat.webui/src/components/file_manage/file_table.tsx @@ -0,0 +1,159 @@ +import { Button, ButtonGroup } from '@heroui/button' +import { Spinner } from '@heroui/spinner' +import { + type Selection, + type SortDescriptor, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow +} from '@heroui/table' +import { Tooltip } from '@heroui/tooltip' +import path from 'path-browserify' +import { BiRename } from 'react-icons/bi' +import { FiCopy, FiMove, FiTrash2 } from 'react-icons/fi' + +import FileIcon from '@/components/file_icon' + +import type { FileInfo } from '@/controllers/file_manager' + +interface FileTableProps { + files: FileInfo[] + currentPath: string + loading: boolean + sortDescriptor: SortDescriptor + onSortChange: (descriptor: SortDescriptor) => void + selectedFiles: Selection + onSelectionChange: (selected: Selection) => void + onDirectoryClick: (dirPath: string) => void + onEdit: (filePath: string) => void + onRenameRequest: (name: string) => void + onMoveRequest: (name: string) => void + onCopyPath: (fileName: string) => void + onDelete: (filePath: string) => void +} + +export default function FileTable({ + files, + currentPath, + loading, + sortDescriptor, + onSortChange, + selectedFiles, + onSelectionChange, + onDirectoryClick, + onEdit, + onRenameRequest, + onMoveRequest, + onCopyPath, + onDelete +}: FileTableProps) { + return ( + + + + 名称 + + + 类型 + + + 大小 + + + 修改时间 + + 操作 + + + + + } + items={files} + > + {(file: FileInfo) => ( + + + + + {file.isDirectory ? '目录' : '文件'} + + {isNaN(file.size) || file.isDirectory ? '-' : `${file.size} 字节`} + + {new Date(file.mtime).toLocaleString()} + + + + + + + + + + + + + + + + + + )} + +
+ ) +} diff --git a/napcat.webui/src/components/file_manage/move_modal.tsx b/napcat.webui/src/components/file_manage/move_modal.tsx new file mode 100644 index 00000000..270aaf9d --- /dev/null +++ b/napcat.webui/src/components/file_manage/move_modal.tsx @@ -0,0 +1,168 @@ +import { Button } from '@heroui/button' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' +import { Spinner } from '@heroui/spinner' +import clsx from 'clsx' +import path from 'path-browserify' +import { useState } from 'react' +import { IoAdd, IoRemove } from 'react-icons/io5' + +import FileManager from '@/controllers/file_manager' + +interface MoveModalProps { + isOpen: boolean + moveTargetPath: string + selectionInfo: string + onClose: () => void + onMove: () => void + onSelect: (dir: string) => void // 新增回调 +} + +// 将 DirectoryTree 改为递归组件 +// 新增 selectedPath 属性,用于标识当前选中的目录 +function DirectoryTree({ + basePath, + onSelect, + selectedPath +}: { + basePath: string + onSelect: (dir: string) => void + selectedPath?: string +}) { + const [dirs, setDirs] = useState([]) + const [expanded, setExpanded] = useState(false) + // 新增loading状态 + const [loading, setLoading] = useState(false) + + const fetchDirectories = async () => { + try { + // 直接使用 basePath 调用接口,移除 process.platform 判断 + const list = await FileManager.listDirectories(basePath) + setDirs(list.map((item) => item.name)) + } catch (error) { + // ...error handling... + } + } + + const handleToggle = async () => { + if (!expanded) { + setExpanded(true) + setLoading(true) + await fetchDirectories() + setLoading(false) + } else { + setExpanded(false) + } + } + + const handleClick = () => { + onSelect(basePath) + handleToggle() + } + + // 计算显示的名称 + const getDisplayName = () => { + if (basePath === '/') return '/' + if (/^[A-Z]:$/i.test(basePath)) return basePath + return path.basename(basePath) + } + + // 更新 Button 的 variant 逻辑 + const isSeleted = selectedPath === basePath + const variant = isSeleted + ? 'solid' + : selectedPath && path.dirname(selectedPath) === basePath + ? 'flat' + : 'light' + + return ( +
+
+ } + > + {getDisplayName()} + + {expanded && ( +
+ {loading ? ( +
+ +
+ ) : ( + dirs.map((dirName) => { + const childPath = + basePath === '/' && /^[A-Z]:$/i.test(dirName) + ? dirName + : path.join(basePath, dirName) + return ( + + ) + }) + )} +
+ )} + + ) +} + +export default function MoveModal({ + isOpen, + moveTargetPath, + selectionInfo, + onClose, + onMove, + onSelect +}: MoveModalProps) { + return ( + + + 选择目标目录 + +
+ +
+

+ 当前选择:{moveTargetPath || '未选择'} +

+

移动项:{selectionInfo}

+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/rename_modal.tsx b/napcat.webui/src/components/file_manage/rename_modal.tsx new file mode 100644 index 00000000..3d0f256c --- /dev/null +++ b/napcat.webui/src/components/file_manage/rename_modal.tsx @@ -0,0 +1,44 @@ +import { Button } from '@heroui/button' +import { Input } from '@heroui/input' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +interface RenameModalProps { + isOpen: boolean + newFileName: string + onNameChange: (e: React.ChangeEvent) => void + onClose: () => void + onRename: () => void +} + +export default function RenameModal({ + isOpen, + newFileName, + onNameChange, + onClose, + onRename +}: RenameModalProps) { + return ( + + + 重命名 + + + + + + + + + + ) +} diff --git a/napcat.webui/src/components/icons.tsx b/napcat.webui/src/components/icons.tsx index 0253ee81..bea77422 100644 --- a/napcat.webui/src/components/icons.tsx +++ b/napcat.webui/src/components/icons.tsx @@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => ( begin="0ms" > ( begin="800ms" > ( begin="1600ms" > ( begin="2400ms" > ( begin="3200ms" > ( begin="0ms" > ( begin="600ms" > ( begin="1200ms" > ( begin="1800ms" > ( begin="2400ms" > ( begin="3000ms" > ( begin="3600ms" > ( begin="4200ms" > ( ) + +export const FileIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const LogIcon = (props: IconSvgProps) => ( + + + + + + + + + + + +) diff --git a/napcat.webui/src/components/tabs/index.tsx b/napcat.webui/src/components/tabs/index.tsx new file mode 100644 index 00000000..9a9afc32 --- /dev/null +++ b/napcat.webui/src/components/tabs/index.tsx @@ -0,0 +1,89 @@ +import clsx from 'clsx' +import { type ReactNode, createContext, forwardRef, useContext } from 'react' + +export interface TabsContextValue { + activeKey: string + onChange: (key: string) => void +} + +const TabsContext = createContext({ + activeKey: '', + onChange: () => {} +}) + +export interface TabsProps { + activeKey: string + onChange: (key: string) => void + children: ReactNode + className?: string +} + +export function Tabs({ activeKey, onChange, children, className }: TabsProps) { + return ( + +
{children}
+
+ ) +} + +export interface TabListProps { + children: ReactNode + className?: string +} + +export function TabList({ children, className }: TabListProps) { + return ( +
{children}
+ ) +} + +export interface TabProps extends React.ButtonHTMLAttributes { + value: string + className?: string + children: ReactNode + isSelected?: boolean +} + +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' + +export interface TabPanelProps { + value: string + children: ReactNode + className?: string +} + +export function TabPanel({ value, children, className }: TabPanelProps) { + const { activeKey } = useContext(TabsContext) + + if (value !== activeKey) return null + + return
{children}
+} 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 new file mode 100644 index 00000000..1b21e382 --- /dev/null +++ b/napcat.webui/src/components/terminal/terminal-instance.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from 'react' + +import TerminalManager from '@/controllers/terminal_manager' + +import XTerm, { XTermRef } from '../xterm' + +interface TerminalInstanceProps { + id: string +} + +export function TerminalInstance({ id }: TerminalInstanceProps) { + const termRef = useRef(null) + + useEffect(() => { + const handleData = (data: string) => { + try { + const parsed = JSON.parse(data) + if (parsed.data) { + termRef.current?.write(parsed.data) + } + } catch (e) { + termRef.current?.write(data) + } + } + + TerminalManager.connectTerminal(id, handleData) + + return () => { + TerminalManager.disconnectTerminal(id, handleData) + } + }, [id]) + + const handleInput = (data: string) => { + TerminalManager.sendInput(id, data) + } + + return +} diff --git a/napcat.webui/src/components/under_construction.tsx b/napcat.webui/src/components/under_construction.tsx new file mode 100644 index 00000000..56097c26 --- /dev/null +++ b/napcat.webui/src/components/under_construction.tsx @@ -0,0 +1,12 @@ +export default function UnderConstruction() { + return ( +
+
+
🚧
+
+ Under Construction +
+
+
+ ) +} diff --git a/napcat.webui/src/components/xterm.tsx b/napcat.webui/src/components/xterm.tsx index 9b2b7a5f..f9ee8a1a 100644 --- a/napcat.webui/src/components/xterm.tsx +++ b/napcat.webui/src/components/xterm.tsx @@ -22,132 +22,146 @@ export type XTermRef = { clear: () => void } -const XTerm = forwardRef>( - (props, ref) => { - const domRef = useRef(null) - const terminalRef = useRef(null) - const { className, ...rest } = props - const { theme } = useTheme() - useEffect(() => { - if (!domRef.current) { - return - } - const terminal = new Terminal({ - allowTransparency: true, - fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', - cursorInactiveStyle: 'outline', - drawBoldTextInBrightColors: false, - letterSpacing: 0, - lineHeight: 1.0 +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, onKey, ...rest } = props + const { theme } = useTheme() + useEffect(() => { + if (!domRef.current) { + return + } + const terminal = new Terminal({ + allowTransparency: true, + fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', + cursorInactiveStyle: 'outline', + drawBoldTextInBrightColors: false + }) + terminalRef.current = terminal + const fitAddon = new FitAddon() + terminal.loadAddon( + new WebLinksAddon((event, uri) => { + if (event.ctrlKey) { + window.open(uri, '_blank') + } }) - terminalRef.current = terminal - const fitAddon = new FitAddon() - terminal.loadAddon( - new WebLinksAddon((event, uri) => { - if (event.ctrlKey) { - window.open(uri, '_blank') - } + ) + terminal.loadAddon(fitAddon) + terminal.loadAddon(new WebglAddon()) + terminal.open(domRef.current) + + terminal.writeln( + gradientText( + 'Welcome to NapCat WebUI', + [255, 0, 0], + [0, 255, 0], + true, + true, + true + ) + ) + + terminal.onData((data) => { + if (onInput) { + onInput(data) + } + }) + + terminal.onKey((event) => { + if (onKey) { + onKey(event.key, event.domEvent) + } + }) + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit() + }) + + // 字体加载完成后重新调整终端大小 + document.fonts.ready.then(() => { + fitAddon.fit() + + resizeObserver.observe(domRef.current!) + }) + + return () => { + resizeObserver.disconnect() + setTimeout(() => { + terminal.dispose() + }, 0) + } + }, []) + + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = { + background: theme === 'dark' ? '#00000000' : '#ffffff00', + foreground: theme === 'dark' ? '#fff' : '#000', + selectionBackground: + theme === 'dark' + ? 'rgba(179, 0, 0, 0.3)' + : 'rgba(255, 167, 167, 0.3)', + cursor: theme === 'dark' ? '#fff' : '#000', + cursorAccent: theme === 'dark' ? '#000' : '#fff', + black: theme === 'dark' ? '#fff' : '#000' + } + terminalRef.current.options.fontWeight = + theme === 'dark' ? 'normal' : '600' + terminalRef.current.options.fontWeightBold = + theme === 'dark' ? 'bold' : '900' + } + }, [theme]) + + useImperativeHandle( + ref, + () => ({ + write: (...args) => { + return terminalRef.current?.write(...args) + }, + writeAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.write(data, resolve) }) - ) - terminal.loadAddon(fitAddon) - terminal.loadAddon(new WebglAddon()) - terminal.open(domRef.current) - - terminal.writeln( - gradientText( - 'Welcome to NapCat WebUI', - [255, 0, 0], - [0, 255, 0], - true, - true, - true - ) - ) - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit() - }) - - // 字体加载完成后重新调整终端大小 - document.fonts.ready.then(() => { - fitAddon.fit() - - resizeObserver.observe(domRef.current!) - }) - - return () => { - resizeObserver.disconnect() - setTimeout(() => { - terminal.dispose() - }, 0) + }, + writeln: (...args) => { + return terminalRef.current?.writeln(...args) + }, + writelnAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.writeln(data, resolve) + }) + }, + clear: () => { + terminalRef.current?.clear() } - }, []) + }), + [] + ) - useEffect(() => { - if (terminalRef.current) { - terminalRef.current.options.theme = { - background: theme === 'dark' ? '#00000000' : '#ffffff00', - foreground: theme === 'dark' ? '#fff' : '#000', - selectionBackground: - theme === 'dark' - ? 'rgba(179, 0, 0, 0.3)' - : 'rgba(255, 167, 167, 0.3)', - cursor: theme === 'dark' ? '#fff' : '#000', - cursorAccent: theme === 'dark' ? '#000' : '#fff', - black: theme === 'dark' ? '#fff' : '#000' - } - terminalRef.current.options.fontWeight = - theme === 'dark' ? 'normal' : '600' - terminalRef.current.options.fontWeightBold = - theme === 'dark' ? 'bold' : '900' - } - }, [theme]) - - useImperativeHandle( - ref, - () => ({ - write: (...args) => { - return terminalRef.current?.write(...args) - }, - writeAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.write(data, resolve) - }) - }, - writeln: (...args) => { - return terminalRef.current?.writeln(...args) - }, - writelnAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.writeln(data, resolve) - }) - }, - clear: () => { - terminalRef.current?.clear() - } - }), - [] - ) - - return ( + return ( +
-
-
- ) - } -) + style={{ + width: '100%', + height: '100%' + }} + ref={domRef} + >
+
+ ) +}) export default XTerm diff --git a/napcat.webui/src/config/site.tsx b/napcat.webui/src/config/site.tsx index 6e25253d..9dfa7ee6 100644 --- a/napcat.webui/src/config/site.tsx +++ b/napcat.webui/src/config/site.tsx @@ -1,6 +1,8 @@ import { BugIcon2, + FileIcon, InfoIcon, + LogIcon, RouteIcon, SettingsIcon, SignalTowerIcon, @@ -49,10 +51,10 @@ export const siteConfig = { href: '/config' }, { - label: '系统日志', + label: 'NapCat日志', icon: (
- +
), href: '/logs' @@ -75,6 +77,24 @@ export const siteConfig = { } ] }, + { + label: '文件管理', + icon: ( +
+ +
+ ), + href: '/file_manager' + }, + { + label: '系统终端', + icon: ( +
+ +
+ ), + href: '/terminal' + }, { label: '关于我们', icon: ( diff --git a/napcat.webui/src/controllers/file_manager.ts b/napcat.webui/src/controllers/file_manager.ts new file mode 100644 index 00000000..77d8599f --- /dev/null +++ b/napcat.webui/src/controllers/file_manager.ts @@ -0,0 +1,98 @@ +import { serverRequest } from '@/utils/request' + +export interface FileInfo { + name: string + isDirectory: boolean + size: number + mtime: Date +} + +export default class FileManager { + public static async listFiles(path: string = '/') { + const { data } = await serverRequest.get>( + `/File/list?path=${encodeURIComponent(path)}` + ) + return data.data + } + + // 新增:按目录获取 + public static async listDirectories(path: string = '/') { + const { data } = await serverRequest.get>( + `/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true` + ) + return data.data + } + + public static async createDirectory(path: string): Promise { + const { data } = await serverRequest.post>( + '/File/mkdir', + { path } + ) + return data.data + } + + public static async delete(path: string) { + const { data } = await serverRequest.post>( + '/File/delete', + { path } + ) + return data.data + } + + public static async readFile(path: string) { + const { data } = await serverRequest.get>( + `/File/read?path=${encodeURIComponent(path)}` + ) + return data.data + } + + public static async writeFile(path: string, content: string) { + const { data } = await serverRequest.post>( + '/File/write', + { path, content } + ) + return data.data + } + + public static async createFile(path: string): Promise { + const { data } = await serverRequest.post>( + '/File/create', + { path } + ) + return data.data + } + + public static async batchDelete(paths: string[]) { + const { data } = await serverRequest.post>( + '/File/batchDelete', + { paths } + ) + return data.data + } + + public static async rename(oldPath: string, newPath: string) { + const { data } = await serverRequest.post>( + '/File/rename', + { oldPath, newPath } + ) + return data.data + } + + public static async move(sourcePath: string, targetPath: string) { + const { data } = await serverRequest.post>( + '/File/move', + { sourcePath, targetPath } + ) + return data.data + } + + public static async batchMove( + items: { sourcePath: string; targetPath: string }[] + ) { + const { data } = await serverRequest.post>( + '/File/batchMove', + { items } + ) + return data.data + } +} diff --git a/napcat.webui/src/controllers/terminal_manager.ts b/napcat.webui/src/controllers/terminal_manager.ts new file mode 100644 index 00000000..a66b5224 --- /dev/null +++ b/napcat.webui/src/controllers/terminal_manager.ts @@ -0,0 +1,118 @@ +import { serverRequest } from '@/utils/request' + +type TerminalCallback = (data: string) => void + +interface TerminalConnection { + ws: WebSocket + callbacks: Set + isConnected: boolean + buffer: string[] // 添加缓存数组 +} + +export interface TerminalSession { + id: string +} + +export interface TerminalInfo { + id: string +} + +class TerminalManager { + private connections: Map = new Map() + private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小 + + async createTerminal(cols: number, rows: number): Promise { + const { data } = await serverRequest.post>( + '/Log/terminal/create', + { cols, rows } + ) + return data.data + } + + async closeTerminal(id: string): Promise { + await serverRequest.post(`/Log/terminal/${id}/close`) + } + + async getTerminalList(): Promise { + const { data } = + await serverRequest.get>( + '/Log/terminal/list' + ) + return data.data + } + + 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 5a3904c3..6476a190 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -9,6 +9,14 @@ export interface Log { message: string } +export interface TerminalSession { + id: string +} + +export interface TerminalInfo { + id: string +} + export default class WebUIManager { public static async checkWebUiLogined() { const { data } = diff --git a/napcat.webui/src/pages/dashboard/file_manager.tsx b/napcat.webui/src/pages/dashboard/file_manager.tsx new file mode 100644 index 00000000..98ac4497 --- /dev/null +++ b/napcat.webui/src/pages/dashboard/file_manager.tsx @@ -0,0 +1,433 @@ +import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs' +import { Button } from '@heroui/button' +import { Input } from '@heroui/input' +import type { Selection, SortDescriptor } from '@react-types/shared' +import path from 'path-browserify' +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { FiMove, FiPlus } from 'react-icons/fi' +import { MdRefresh } from 'react-icons/md' +import { TbTrash } from 'react-icons/tb' +import { TiArrowBack } from 'react-icons/ti' +import { useLocation, useNavigate } from 'react-router-dom' + +import CreateFileModal from '@/components/file_manage/create_file_modal' +import FileEditModal from '@/components/file_manage/file_edit_modal' +import FileTable from '@/components/file_manage/file_table' +import MoveModal from '@/components/file_manage/move_modal' +import RenameModal from '@/components/file_manage/rename_modal' + +import useDialog from '@/hooks/use-dialog' + +import FileManager, { FileInfo } from '@/controllers/file_manager' + +export default function FileManagerPage() { + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'name', + direction: 'ascending' + }) + const dialog = useDialog() + const location = useLocation() + const navigate = useNavigate() + // 修改 currentPath 初始化逻辑,去掉可能的前导斜杠 + let currentPath = decodeURIComponent(location.hash.slice(1) || '/') + if (/^\/[A-Z]:$/i.test(currentPath)) { + currentPath = currentPath.slice(1) + } + const [editingFile, setEditingFile] = useState<{ + path: string + content: string + } | null>(null) + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [newFileName, setNewFileName] = useState('') + const [fileType, setFileType] = useState<'file' | 'directory'>('file') + const [selectedFiles, setSelectedFiles] = useState(new Set()) + const [isRenameModalOpen, setIsRenameModalOpen] = useState(false) + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false) + const [renamingFile, setRenamingFile] = useState('') + const [moveTargetPath, setMoveTargetPath] = useState('') + const [jumpPath, setJumpPath] = useState('') + + const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => { + return [...files].sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + const direction = descriptor.direction === 'ascending' ? 1 : -1 + switch (descriptor.column) { + case 'name': + return direction * a.name.localeCompare(b.name) + case 'type': { + const aType = a.isDirectory ? '目录' : '文件' + const bType = a.isDirectory ? '目录' : '文件' + return direction * aType.localeCompare(bType) + } + case 'size': + return direction * ((a.size || 0) - (b.size || 0)) + case 'mtime': + return ( + direction * + (new Date(a.mtime).getTime() - new Date(b.mtime).getTime()) + ) + default: + return 0 + } + }) + } + + const loadFiles = async () => { + setLoading(true) + try { + const fileList = await FileManager.listFiles(currentPath) + setFiles(sortFiles(fileList, sortDescriptor)) + } catch (error) { + toast.error('加载文件列表失败') + setFiles([]) + } + setLoading(false) + } + + useEffect(() => { + loadFiles() + }, [currentPath]) + + const handleSortChange = (descriptor: typeof sortDescriptor) => { + setSortDescriptor(descriptor) + setFiles((prev) => sortFiles(prev, descriptor)) + } + + const handleDirectoryClick = (dirPath: string) => { + if (dirPath === '..') { + if (/^[A-Z]:$/i.test(currentPath)) { + navigate('/file_manager#/') + return + } + const parentPath = path.dirname(currentPath) + navigate( + `/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}` + ) + return + } + navigate( + `/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}` + ) + } + + const handleEdit = async (filePath: string) => { + try { + const content = await FileManager.readFile(filePath) + setEditingFile({ path: filePath, content }) + } catch (error) { + toast.error('打开文件失败') + } + } + + const handleSave = async () => { + if (!editingFile) return + try { + await FileManager.writeFile(editingFile.path, editingFile.content) + toast.success('保存成功') + setEditingFile(null) + loadFiles() + } catch (error) { + toast.error('保存失败') + } + } + + const handleDelete = async (filePath: string) => { + dialog.confirm({ + title: '删除文件', + content:
确定要删除文件 {filePath} 吗?
, + onConfirm: async () => { + try { + await FileManager.delete(filePath) + toast.success('删除成功') + loadFiles() + } catch (error) { + toast.error('删除失败') + } + } + }) + } + + const handleCreate = async () => { + if (!newFileName) return + const newPath = path.join(currentPath, newFileName) + try { + if (fileType === 'directory') { + if (!(await FileManager.createDirectory(newPath))) { + toast.error('目录已存在') + return + } + } else { + if (!(await FileManager.createFile(newPath))) { + toast.error('文件已存在') + return + } + } + toast.success('创建成功') + setIsCreateModalOpen(false) + setNewFileName('') + loadFiles() + } catch (error) { + toast.error((error as Error)?.message || '创建失败') + } + } + + const handleBatchDelete = async () => { + const selectedArray = + selectedFiles instanceof Set + ? Array.from(selectedFiles) + : files.map((f) => f.name) + if (selectedArray.length === 0) return + dialog.confirm({ + title: '批量删除', + content:
确定要删除选中的 {selectedArray.length} 个项目吗?
, + onConfirm: async () => { + try { + const paths = selectedArray.map((key) => + path.join(currentPath, key.toString()) + ) + await FileManager.batchDelete(paths) + toast.success('批量删除成功') + setSelectedFiles(new Set()) + loadFiles() + } catch (error) { + toast.error('批量删除失败') + } + } + }) + } + + const handleRename = async () => { + if (!renamingFile || !newFileName) return + try { + await FileManager.rename( + path.join(currentPath, renamingFile), + path.join(currentPath, newFileName) + ) + toast.success('重命名成功') + setIsRenameModalOpen(false) + setRenamingFile('') + setNewFileName('') + loadFiles() + } catch (error) { + toast.error('重命名失败') + } + } + + const handleMove = async (sourceName: string) => { + if (!moveTargetPath) return + try { + await FileManager.move( + path.join(currentPath, sourceName), + path.join(moveTargetPath, sourceName) + ) + toast.success('移动成功') + setIsMoveModalOpen(false) + setMoveTargetPath('') + loadFiles() + } catch (error) { + toast.error('移动失败') + } + } + + const handleBatchMove = async () => { + if (!moveTargetPath) return + const selectedArray = + selectedFiles instanceof Set + ? Array.from(selectedFiles) + : files.map((f) => f.name) + if (selectedArray.length === 0) return + try { + const items = selectedArray.map((name) => ({ + sourcePath: path.join(currentPath, name.toString()), + targetPath: path.join(moveTargetPath, name.toString()) + })) + await FileManager.batchMove(items) + toast.success('批量移动成功') + setIsMoveModalOpen(false) + setMoveTargetPath('') + setSelectedFiles(new Set()) + loadFiles() + } catch (error) { + toast.error('批量移动失败') + } + } + + const handleCopyPath = (fileName: string) => { + navigator.clipboard.writeText(path.join(currentPath, fileName)) + toast.success('路径已复制') + } + + const handleMoveClick = (fileName: string) => { + setRenamingFile(fileName) + setMoveTargetPath('') + setIsMoveModalOpen(true) + } + + return ( +
+
+ + + + + + {((selectedFiles instanceof Set && selectedFiles.size > 0) || + selectedFiles === 'all') && ( + <> + + + + )} + + {currentPath.split('/').map((part, index, parts) => ( + { + const newPath = parts.slice(0, index + 1).join('/') + navigate(`/file_manager#${encodeURIComponent(newPath)}`) + }} + > + {part} + + ))} + + setJumpPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPath.trim() !== '') { + navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`) + } + }} + className="ml-auto w-64" + /> +
+ + { + setRenamingFile(name) + setNewFileName(name) + setIsRenameModalOpen(true) + }} + onMoveRequest={handleMoveClick} + onCopyPath={handleCopyPath} + onDelete={handleDelete} + /> + + setEditingFile(null)} + onSave={handleSave} + onContentChange={(newContent) => + setEditingFile((prev) => + prev ? { ...prev, content: newContent ?? '' } : null + ) + } + /> + + setNewFileName(e.target.value)} + onClose={() => setIsCreateModalOpen(false)} + onCreate={handleCreate} + /> + + setNewFileName(e.target.value)} + onClose={() => setIsRenameModalOpen(false)} + onRename={handleRename} + /> + + 0 + ? `${selectedFiles.size} 个项目` + : renamingFile + } + onClose={() => setIsMoveModalOpen(false)} + onMove={() => + selectedFiles instanceof Set && selectedFiles.size > 0 + ? handleBatchMove() + : handleMove(renamingFile) + } + onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange + /> +
+ ) +} diff --git a/napcat.webui/src/pages/dashboard/terminal.tsx b/napcat.webui/src/pages/dashboard/terminal.tsx new file mode 100644 index 00000000..c95e0ee1 --- /dev/null +++ b/napcat.webui/src/pages/dashboard/terminal.tsx @@ -0,0 +1,171 @@ +import { + DndContext, + DragEndEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy +} from '@dnd-kit/sortable' +import { Button } from '@heroui/button' +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { IoAdd, IoClose } from 'react-icons/io5' + +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' + +interface TerminalTab { + id: string + title: string +} + +export default function TerminalPage() { + const [tabs, setTabs] = useState([]) + const [selectedTab, setSelectedTab] = useState('') + + useEffect(() => { + // 获取已存在的终端列表 + terminalManager.getTerminalList().then((terminals) => { + if (terminals.length === 0) return + + const newTabs = terminals.map((terminal) => ({ + id: terminal.id, + title: terminal.id + })) + + setTabs(newTabs) + setSelectedTab(newTabs[0].id) + }) + }, []) + + const createNewTerminal = async () => { + try { + const { id } = await terminalManager.createTerminal(80, 24) + const newTab = { + id, + title: id + } + + setTabs((prev) => [...prev, newTab]) + setSelectedTab(id) + } catch (error) { + console.error('Failed to create terminal:', error) + toast.error('创建终端失败') + } + } + + const closeTerminal = async (id: string) => { + try { + await terminalManager.closeTerminal(id) + terminalManager.removeTerminal(id) + if (selectedTab === 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('关闭终端失败') + } + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (active.id !== over?.id) { + setTabs((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id) + const newIndex = items.findIndex((item) => item.id === over?.id) + return arrayMove(items, oldIndex, newIndex) + }) + } + } + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8 + } + }) + ) + + return ( +
+ + +
+ + + {tabs.map((tab) => ( + + {tab.title} + + + ))} + + +
+
+ {tabs.length === 0 && ( +
+ +
点击右上角按钮创建终端
+
+ )} + {tabs.map((tab) => ( + + + + ))} +
+
+
+
+ ) +} diff --git a/napcat.webui/src/pages/index.tsx b/napcat.webui/src/pages/index.tsx index 7fe42c6c..8a94ec5a 100644 --- a/napcat.webui/src/pages/index.tsx +++ b/napcat.webui/src/pages/index.tsx @@ -9,8 +9,10 @@ import ConfigPage from './dashboard/config' import DebugPage from './dashboard/debug' import HttpDebug from './dashboard/debug/http' import WSDebug from './dashboard/debug/websocket' +import FileManagerPage from './dashboard/file_manager' import LogsPage from './dashboard/logs' import NetworkPage from './dashboard/network' +import TerminalPage from './dashboard/terminal' export default function IndexPage() { const location = useLocation() @@ -33,6 +35,8 @@ export default function IndexPage() { } /> } /> + } path="/file_manager" /> + } path="/terminal" /> } path="/about" /> 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..b25d23d6 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,17 +45,20 @@ "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", "vite-plugin-cp": "^4.0.8", "vite-tsconfig-paths": "^5.1.0", - "winston": "^3.17.0" + "winston": "^3.17.0", + "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", + "@ffmpeg.wasm/main": "^0.13.1" }, "dependencies": { "@ffmpeg.wasm/core-mt": "^0.13.2", - "@ffmpeg.wasm/main": "^0.13.1", "express": "^5.0.0", + "express-rate-limit": "^7.5.0", "piscina": "^4.7.0", "qrcode-terminal": "^0.12.0", "silk-wasm": "^3.6.1", diff --git a/src/native/pty/darwin.win64/pty.node b/src/native/pty/darwin.win64/pty.node new file mode 100644 index 00000000..db6e9fd8 Binary files /dev/null and b/src/native/pty/darwin.win64/pty.node differ diff --git a/src/native/pty/darwin.win64/spawn-helper b/src/native/pty/darwin.win64/spawn-helper new file mode 100644 index 00000000..56bd1b59 Binary files /dev/null and b/src/native/pty/darwin.win64/spawn-helper differ diff --git a/src/native/pty/darwin.x64/pty.node b/src/native/pty/darwin.x64/pty.node new file mode 100644 index 00000000..001b59a5 Binary files /dev/null and b/src/native/pty/darwin.x64/pty.node differ diff --git a/src/native/pty/darwin.x64/spawn-helper b/src/native/pty/darwin.x64/spawn-helper new file mode 100644 index 00000000..852a6bdc Binary files /dev/null and b/src/native/pty/darwin.x64/spawn-helper differ diff --git a/src/native/pty/linux.arm64/pty.node b/src/native/pty/linux.arm64/pty.node new file mode 100644 index 00000000..0dcd051c Binary files /dev/null and b/src/native/pty/linux.arm64/pty.node differ diff --git a/src/native/pty/linux.x64/pty.node b/src/native/pty/linux.x64/pty.node new file mode 100644 index 00000000..08961b74 Binary files /dev/null and b/src/native/pty/linux.x64/pty.node differ diff --git a/src/native/pty/win32.x64/conpty.node b/src/native/pty/win32.x64/conpty.node new file mode 100644 index 00000000..17226863 Binary files /dev/null and b/src/native/pty/win32.x64/conpty.node differ diff --git a/src/native/pty/win32.x64/conpty_console_list.node b/src/native/pty/win32.x64/conpty_console_list.node new file mode 100644 index 00000000..f7cba591 Binary files /dev/null and b/src/native/pty/win32.x64/conpty_console_list.node differ diff --git a/src/native/pty/win32.x64/pty.node b/src/native/pty/win32.x64/pty.node new file mode 100644 index 00000000..519db1c6 Binary files /dev/null and b/src/native/pty/win32.x64/pty.node differ diff --git a/src/native/pty/win32.x64/winpty-agent.exe b/src/native/pty/win32.x64/winpty-agent.exe new file mode 100644 index 00000000..cd7dca46 Binary files /dev/null and b/src/native/pty/win32.x64/winpty-agent.exe differ diff --git a/src/native/pty/win32.x64/winpty.dll b/src/native/pty/win32.x64/winpty.dll new file mode 100644 index 00000000..55b4a14a Binary files /dev/null and b/src/native/pty/win32.x64/winpty.dll differ diff --git a/src/pty/index.ts b/src/pty/index.ts new file mode 100644 index 00000000..a8c9cff5 --- /dev/null +++ b/src/pty/index.ts @@ -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); diff --git a/src/pty/native.d.ts b/src/pty/native.d.ts new file mode 100644 index 00000000..46821efb --- /dev/null +++ b/src/pty/native.d.ts @@ -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; + } diff --git a/src/pty/node-pty.d.ts b/src/pty/node-pty.d.ts new file mode 100644 index 00000000..0a61f88c --- /dev/null +++ b/src/pty/node-pty.d.ts @@ -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; + + /** + * 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 { + (listener: (e: T) => any): IDisposable; + } + } + \ No newline at end of file diff --git a/src/pty/prebuild-loader.ts b/src/pty/prebuild-loader.ts new file mode 100644 index 00000000..61794eb7 --- /dev/null +++ b/src/pty/prebuild-loader.ts @@ -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; +}; diff --git a/src/pty/unixTerminal.ts b/src/pty/unixTerminal.ts new file mode 100644 index 00000000..85a8d469 --- /dev/null +++ b/src/pty/unixTerminal.ts @@ -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']; + } +} diff --git a/src/pty/windowsConoutConnection.ts b/src/pty/windowsConoutConnection.ts new file mode 100644 index 00000000..e1d303a1 --- /dev/null +++ b/src/pty/windowsConoutConnection.ts @@ -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(); + public get onReady(): IEvent { 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 { + await this._worker.terminate(); + } +} diff --git a/src/pty/windowsPtyAgent.ts b/src/pty/windowsPtyAgent.ts new file mode 100644 index 00000000..fca9a273 --- /dev/null +++ b/src/pty/windowsPtyAgent.ts @@ -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 { + return new Promise(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)); +} diff --git a/src/pty/windowsTerminal.ts b/src/pty/windowsTerminal.ts new file mode 100644 index 00000000..9aae295e --- /dev/null +++ b/src/pty/windowsTerminal.ts @@ -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 ((err).code) { + if (~(err).code.indexOf('errno 5') || ~(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(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(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'); } +} diff --git a/src/pty/worker/conoutSocketWorker.ts b/src/pty/worker/conoutSocketWorker.ts new file mode 100644 index 00000000..64aeed00 --- /dev/null +++ b/src/pty/worker/conoutSocketWorker.ts @@ -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); +}); diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index f2a05426..da052d41 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,3 +1,2 @@ import { NCoreInitShell } from "./base"; - NCoreInitShell(); \ No newline at end of file diff --git a/src/webui/index.ts b/src/webui/index.ts index 80b2e7ba..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'; @@ -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 }; diff --git a/src/webui/src/api/File.ts b/src/webui/src/api/File.ts new file mode 100644 index 00000000..1da78820 --- /dev/null +++ b/src/webui/src/api/File.ts @@ -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 => { + 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 => { + 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, '批量移动失败'); + } +}; diff --git a/src/webui/src/api/Log.ts b/src/webui/src/api/Log.ts index eca333c5..713d5adc 100644 --- a/src/webui/src/api/Log.ts +++ b/src/webui/src/api/Log.ts @@ -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, {}); +}; diff --git a/src/webui/src/router/File.ts b/src/webui/src/router/File.ts new file mode 100644 index 00000000..c282d229 --- /dev/null +++ b/src/webui/src/router/File.ts @@ -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 }; diff --git a/src/webui/src/router/Log.ts b/src/webui/src/router/Log.ts index b72a2245..9ec28887 100644 --- a/src/webui/src/router/Log.ts +++ b/src/webui/src/router/Log.ts @@ -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 }; diff --git a/src/webui/src/router/index.ts b/src/webui/src/router/index.ts index eaa960d3..8ea50102 100644 --- a/src/webui/src/router/index.ts +++ b/src/webui/src/router/index.ts @@ -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 }; diff --git a/src/webui/src/terminal/init-dynamic-dirname.ts b/src/webui/src/terminal/init-dynamic-dirname.ts new file mode 100644 index 00000000..9c6ee8d8 --- /dev/null +++ b/src/webui/src/terminal/init-dynamic-dirname.ts @@ -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) : ''; + }, +}); diff --git a/src/webui/src/terminal/terminal_manager.ts b/src/webui/src/terminal/terminal_manager.ts new file mode 100644 index 00000000..2d9c6e01 --- /dev/null +++ b/src/webui/src/terminal/terminal_manager.ts @@ -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; + // 新增标识,用于防止重复关闭 + isClosing: boolean; +} + +class TerminalManager { + private terminals: Map = 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(); diff --git a/vite.config.ts b/vite.config.ts index 151f3c14..e58b2c61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,14 @@ 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' +]; const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); let startScripts: string[] | undefined = undefined; @@ -22,6 +29,7 @@ const UniversalBaseConfigPlugin: PluginOption[] = [ { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/framework/liteloader.cjs', dest: 'dist' }, { src: './src/framework/napcat.cjs', dest: 'dist' }, @@ -44,6 +52,7 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [ { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/framework/liteloader.cjs', dest: 'dist' }, { src: './src/framework/napcat.cjs', dest: 'dist' }, @@ -56,11 +65,11 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [ nodeResolve(), ]; - const ShellBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, + { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './package.json', dest: 'dist' }, @@ -91,6 +100,7 @@ const UniversalBaseConfig = () => napcat: 'src/universal/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -101,7 +111,6 @@ const UniversalBaseConfig = () => }, }); - const ShellBaseConfig = () => defineConfig({ resolve: { @@ -121,6 +130,7 @@ const ShellBaseConfig = () => napcat: 'src/shell/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`, @@ -150,6 +160,7 @@ const FrameworkBaseConfig = () => napcat: 'src/framework/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', + 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], fileName: (_, entryName) => `${entryName}.mjs`,