chore: remote forwarding integration

This commit is contained in:
2026-07-17 18:16:11 +08:00
parent d12c74649a
commit ec9f854136
14 changed files with 1660 additions and 77 deletions
+236 -2
View File
@@ -10,7 +10,8 @@ import {
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates,
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation,
recordAudit, listAudit, pruneAudit
} from './db'
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
import { ChatServer } from './chat-server'
@@ -19,7 +20,11 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef } from './protocol'
import { TerminalManager } from './remote/terminal'
import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
interface NetCtx {
self: DeviceInfo
@@ -27,6 +32,9 @@ interface NetCtx {
chatServer: ChatServer
chatClient: ChatClient
fileServer: FileServer
terminal: TerminalManager
forward: ForwardManager
usb: UsbBridgeManager
}
let ctx: NetCtx | null = null
@@ -159,6 +167,40 @@ export function bindNetworkContext(c: NetCtx) {
else nextStatus = 'delivered'
updateMessageStatus(messageId, nextStatus)
broadcastToRenderer('message:statusChanged', { messageId, status: nextStatus })
return
}
if (frame.type === 'terminal') {
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn('[terminal] handle error:', e?.message))
return
}
if (frame.type === 'forward') {
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
return
}
if (frame.type === 'usb') {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
})
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
// 这里分发 remote 帧给本地 manager (peer -> 我们) — 控制端发的请求落在这里
c.chatServer.on('frame', (peer, frame) => {
if (frame.type === 'terminal') {
console.log(`[ipc] incoming terminal frame from ${peer.name}: ${frame.payload.type}`)
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn('[terminal] handle error:', e?.message))
return
}
if (frame.type === 'forward') {
console.log(`[ipc] incoming forward frame from ${peer.name}: ${(frame.payload as any).type}`)
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
return
}
if (frame.type === 'usb') {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
})
@@ -234,6 +276,57 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('message:statusChanged', { messageId: r.message_id, status: 'read' })
}
})
// ===== Remote: terminal / forward / usb 事件桥接 =====
c.terminal.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
// 控制端从被控端收到 output -> 给 UI
broadcastToRenderer('remote:terminal:output', { sessionId, peerId, data: bytes.toString('base64') })
})
c.terminal.on('sessionOpened', (sessionId: string, peerId: string, info: { shell: string; rows: number; cols: number }) => {
broadcastToRenderer('remote:terminal:opened', { sessionId, peerId, ...info })
})
c.terminal.on('sessionClosed', (sessionId: string, peerId: string, reason?: string) => {
broadcastToRenderer('remote:terminal:closed', { sessionId, peerId, reason })
})
c.terminal.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'terminal' })
})
c.forward.on('sessionOpened', (info) => {
broadcastToRenderer('remote:forward:opened', info)
})
c.forward.on('sessionClosed', (info) => {
broadcastToRenderer('remote:forward:closed', info)
})
c.forward.on('sessionError', (info) => {
broadcastToRenderer('remote:forward:error', info)
})
c.forward.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'forward' })
})
c.usb.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
broadcastToRenderer('remote:usb:output', { sessionId, peerId, data: bytes.toString('base64') })
})
c.usb.on('sessionOpened', (info) => {
broadcastToRenderer('remote:usb:opened', info)
})
c.usb.on('sessionClosed', (info) => {
broadcastToRenderer('remote:usb:closed', info)
})
c.usb.on('sessionError', (info) => {
broadcastToRenderer('remote:usb:error', info)
})
c.usb.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'usb' })
})
// 对端 ws 断开 -> 清理该对端的所有 session
c.chatClient.on('close', (peer) => {
c.terminal.onPeerDisconnected(peer.deviceId)
c.forward.onPeerDisconnected(peer.deviceId)
c.usb.onPeerDisconnected(peer.deviceId)
})
}
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
@@ -911,6 +1004,147 @@ export function registerIpc() {
return false
}
})
// ===== 远程: terminal =====
ipcMain.handle('terminal:open', async (_e, args: { peerId: string; rows?: number; cols?: number; readOnly?: boolean }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.terminal.openOnPeer(args.peerId, {
rows: args.rows ?? 24,
cols: args.cols ?? 80,
readOnly: args.readOnly,
})
})
ipcMain.handle('terminal:input', (_e, args: { sessionId: string; dataBase64: string }) => {
if (!ctx) return false
if (!args?.sessionId || !args?.dataBase64) return false
const buf = Buffer.from(args.dataBase64, 'base64')
return ctx.terminal.sendInput(args.sessionId, buf)
})
ipcMain.handle('terminal:resize', (_e, args: { sessionId: string; rows: number; cols: number }) => {
if (!ctx) return false
return ctx.terminal.resize(args.sessionId, args.rows, args.cols)
})
ipcMain.handle('terminal:close', (_e, args: { sessionId: string; reason?: string }) => {
if (!ctx) return false
ctx.terminal.close(args.sessionId, args.reason)
return true
})
ipcMain.handle('terminal:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.terminal.listClientSessions(),
server: ctx.terminal.listServerSessions(),
}
})
// ===== 远程: forward =====
ipcMain.handle('forward:open', async (_e, args: { peerId: string; direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.forward.openOnPeer(args.peerId, args)
})
ipcMain.handle('forward:close', (_e, args: { sessionId: string }) => {
if (!ctx) return false
// 控制端: 关 clientSession
ctx.forward.cleanupClientSession(args.sessionId, 'user')
return true
})
ipcMain.handle('forward:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.forward.listClientSessions(),
server: ctx.forward.listServerSessions(),
}
})
// ===== 远程: usb (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) =====
ipcMain.handle('usb:list', async (_e, args: { peerId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.listOnPeer(args.peerId)
})
// 列本机 (串口 + libusb + linux usbip) — self-in 模式用
ipcMain.handle('usb:listLocal', async () => {
if (!ctx) return { ok: false, reason: 'no ctx' }
try {
const devices = await ctx.usb.listLocal()
return { ok: true, devices }
} catch (e: any) {
return { ok: false, reason: String(e?.message || e), devices: [] }
}
})
// attach: 由 renderer 把 UsbAttachConfig 传过来
ipcMain.handle('usb:attach', async (_e, args: { peerId: string; direction?: UsbDirection; busId: string; config: UsbAttachConfig }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config })
})
// 串口: 发送字节 (client 端)
ipcMain.handle('usb:serialSend', (_e, args: { sessionId: string; dataBase64: string }) => {
if (!ctx) return false
const buf = Buffer.from(args.dataBase64, 'base64')
return ctx.usb.sendSerialBytes(args.sessionId, buf)
})
// USB 控制传输 (control OUT / control IN)
ipcMain.handle('usb:ctrlOut', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; dataBase64?: string }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbCtrlTransfer(args.sessionId, args.setup, args.dataBase64)
})
ipcMain.handle('usb:ctrlIn', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; length: number }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbCtrlIn(args.sessionId, args.setup, args.length)
})
// USB 批量传输 (bulk OUT / bulk IN)
ipcMain.handle('usb:bulkOut', async (_e, args: { sessionId: string; endpoint: number; dataBase64: string }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbBulkOut(args.sessionId, args.endpoint, args.dataBase64)
})
ipcMain.handle('usb:bulkIn', async (_e, args: { sessionId: string; endpoint: number; length: number; timeoutMs?: number }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbBulkIn(args.sessionId, args.endpoint, args.length, args.timeoutMs)
})
ipcMain.handle('usb:detach', async (_e, args: { sessionId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.detach(args.sessionId, 'user')
})
ipcMain.handle('usb:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.usb.listClientSessions(),
server: ctx.usb.listServerSessions(),
}
})
// ===== 授权 / 审计 =====
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
return replyApproval(args.requestId, args.ok, args.remember)
})
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
return listAudit(args || {})
})
ipcMain.handle('audit:prune', (_e, olderThanMs?: number) => {
const days = getSettings().auditRetentionDays
const ms = olderThanMs ?? Math.max(1, days) * 24 * 3600 * 1000
return pruneAudit(ms)
})
}
function rowToEnvelope(row: any): MessageEnvelope {