a79086e4e8
新增网盘功能 - FileServer 增加 GET /drive/meta|list|download, 每机长期 token 校验 + 目录穿越防护 - 新增 drive.ts 客户端: listDrive / getDriveRoot / downloadDriveFile / collectDriveFiles - ipc 暴露 drive:list|root|download|openFolder|pickDir, DB 增 drive_token 列 + ALTER 迁移 - 新增 DrivePanel.vue, 聊天框加"网盘" tab, 设置页加网盘分区 - 支持整目录递归下载并保持层级 修复远程功能 - 终端: 控制端收到的 output 帧被直接丢弃 (case 'output': return), 导致 xterm 全空白 - ChatServer: ws 构造即 listen, 'listening' 可能早于 start() 挂监听, 导致永久挂起 - USB: serverAttach 自行生成 sessionId 与控制端不一致 (数据帧查表落空); libusb 枚举误用 getDevices() (WebUSB 语义, 只返回已授权设备) 改 loadDevices(); control transfer 传数字 bmRequestType (node-usb v3 要字符串枚举); detachKernelDriver 挪到 selectConfiguration 之前 (否则 EBUSY) - 转发 UI: store 的 onOpened 是 no-op 致对方发起的转发不显示、无轮询致流量/TTL 不刷新、 对方发起的会话关不掉 (新增 closeSession, server 侧关闭需通知对端否则端口泄漏)、 入口/目标补归属标注、方向标签语义统一、监听端口输入框加宽 修复"在线却显示等待重连" - 根因: 启动时主进程先连上对端 (connectTo 早于 createMainWindow), device:wsState 在渲染层订阅前发出去被丢弃; 之后连接一直保持, 再无 open 事件 -> UI 永久停在等待重连 - 改为推全量快照 + 渲染层 mount 时主动拉取 (app:state); 渲染层先注册监听再 await - 通道语义改为 outgoing 或 inbound 任一 WS 活着即可用 (原来只看 outgoing) 修复 Linux 安装后没图标 - linux.icon 指向单张 PNG 时 app-builder 解析不出尺寸 (size=0), 图标被装进 hicolor/0x0/ 非法目录, 桌面环境找不到; 改为指向 resources/icons 目录 (8 个标准尺寸) - StartupWMClass 填的是 productName, 与窗口真实 WM_CLASS (可执行文件名) 对不上, 任务栏找不到启动器; 改为 local-net-msg 其他 - 新增 scripts/selftest 自测框架 (两个虚拟节点 + 真实 ChatServer/ChatClient), 30 条用例全过 - 重写 scripts/make-icon.js 生成多尺寸图标集 (原脚本引用的源图已不存在, sharp 依赖也缺) - 新增 docs/drive.md, docs/connection-status.md
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
// WebSocket 服务端: 监听来自其它设备的连接, 接收消息/ping/recall
|
|
import { WebSocketServer, WebSocket } from 'ws'
|
|
import { EventEmitter } from 'node:events'
|
|
import type { WsFrame, DeviceInfo, MessageEnvelope } from './protocol'
|
|
import { PROTOCOL_VERSION } from './protocol'
|
|
|
|
export interface ChatServerEvents {
|
|
connect: (ws: WebSocket, peer: DeviceInfo) => void
|
|
disconnect: (deviceId: string) => void
|
|
message: (from: DeviceInfo, msg: MessageEnvelope) => void
|
|
recall: (from: DeviceInfo, messageId: string) => void
|
|
typing: (from: DeviceInfo) => void
|
|
read: (from: DeviceInfo, upToMessageId: string) => void
|
|
// 任意 ws 帧 (含 terminal/forward/usb 等), 让上层路由分发
|
|
frame: (from: DeviceInfo, frame: WsFrame) => void
|
|
}
|
|
|
|
export class ChatServer extends EventEmitter {
|
|
private wss: WebSocketServer
|
|
private port: number
|
|
// 一个 device 可能多个连接 (重连), 只信任最新 hello 的
|
|
private sockets = new Map<string, WebSocket>()
|
|
|
|
constructor(port: number) {
|
|
super()
|
|
this.port = port
|
|
this.wss = new WebSocketServer({ port, host: '0.0.0.0' })
|
|
// 连接处理必须在这里挂: ws 在构造时就 listen, 'listening' 事件可能早于 start() 挂监听而丢失,
|
|
// 那种情况下 start() 走 "已在监听" 分支, 不会再走到下面的 on('connection')
|
|
this.wss.on('connection', (ws, req) => this.onConnection(ws, req))
|
|
}
|
|
|
|
getPort(): number {
|
|
const addr = this.wss.address()
|
|
return typeof addr === 'object' && addr ? addr.port : this.port
|
|
}
|
|
|
|
start(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
// ws 的 WebSocketServer 在构造时就 listen 了, 'listening' 事件可能早于本方法挂监听而丢失
|
|
// (例如同一 tick 里先 await 了别的东西再调 start()) —— 这里先看是否已经在监听
|
|
const addr = this.wss.address()
|
|
if (addr && typeof addr === 'object' && addr.port) {
|
|
this.port = addr.port
|
|
resolve(addr.port)
|
|
return
|
|
}
|
|
this.wss.once('error', reject)
|
|
this.wss.once('listening', () => {
|
|
const a = this.wss.address()
|
|
const realPort = typeof a === 'object' && a ? a.port : this.port
|
|
this.port = realPort
|
|
resolve(realPort)
|
|
})
|
|
})
|
|
}
|
|
|
|
private onConnection(ws: WebSocket, _req: any) {
|
|
let peer: DeviceInfo | null = null
|
|
|
|
ws.on('message', (raw) => {
|
|
let frame: WsFrame
|
|
try {
|
|
frame = JSON.parse(raw.toString('utf8'))
|
|
} catch {
|
|
return
|
|
}
|
|
// 通用: 任何帧都抛 'frame', 上层 (ipc.bindNetworkContext) 自行路由
|
|
if (peer) this.emit('frame', peer, frame)
|
|
switch (frame.type) {
|
|
case 'hello':
|
|
if (frame.from && frame.from.deviceId) {
|
|
peer = frame.from
|
|
// 版本/协议不匹配 -> 关闭
|
|
if ((peer as any).version && (peer as any).version !== PROTOCOL_VERSION) {
|
|
ws.close(4001, 'protocol mismatch')
|
|
return
|
|
}
|
|
const old = this.sockets.get(peer.deviceId)
|
|
if (old && old !== ws) {
|
|
try { old.close(4000, 'replaced') } catch {}
|
|
}
|
|
this.sockets.set(peer.deviceId, ws)
|
|
this.emit('connect', ws, peer)
|
|
}
|
|
break
|
|
case 'ping':
|
|
this.sendTo(ws, { type: 'pong', ts: frame.ts })
|
|
break
|
|
case 'message':
|
|
if (peer) this.emit('message', peer, frame.payload)
|
|
break
|
|
case 'recall':
|
|
if (peer) this.emit('recall', peer, frame.messageId)
|
|
break
|
|
case 'read':
|
|
if (peer) this.emit('read', peer, frame.upTo)
|
|
break
|
|
case 'typing':
|
|
if (peer) this.emit('typing', peer)
|
|
break
|
|
// terminal / forward / usb 由 'frame' 事件统一交给上层处理
|
|
}
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
if (peer) {
|
|
// 只有当这个 ws 仍是该 device 的代表 socket 才触发 disconnect
|
|
if (this.sockets.get(peer.deviceId) === ws) {
|
|
this.sockets.delete(peer.deviceId)
|
|
this.emit('disconnect', peer.deviceId)
|
|
}
|
|
}
|
|
})
|
|
|
|
ws.on('error', () => { /* swallow */ })
|
|
}
|
|
|
|
sendTo(ws: WebSocket, frame: WsFrame) {
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
|
|
}
|
|
|
|
sendToDevice(deviceId: string, frame: WsFrame): boolean {
|
|
const ws = this.sockets.get(deviceId)
|
|
if (!ws) return false
|
|
this.sendTo(ws, frame)
|
|
return true
|
|
}
|
|
|
|
// 该设备是否有活着的入站连接 (对方主动连过来的 ws)
|
|
// 与 chat-client 的 outgoing 相对: 任何一条通, 消息通道就是通的
|
|
isConnected(deviceId: string): boolean {
|
|
const ws = this.sockets.get(deviceId)
|
|
return !!ws && ws.readyState === WebSocket.OPEN
|
|
}
|
|
|
|
async stop() {
|
|
for (const ws of this.sockets.values()) {
|
|
try { ws.close(1001, 'server shutdown') } catch {}
|
|
}
|
|
await new Promise<void>(r => this.wss.close(() => r()))
|
|
}
|
|
}
|