// IPC 处理: 渲染进程 -> 主进程 的所有调用 import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron' import { randomUUID } from 'node:crypto' import { existsSync, statSync, readFileSync } from 'node:fs' import { basename, join, dirname } from 'node:path' import { promises as fsp } from 'node:fs' import { getSettings, updateSettings, ensureDownloadDir, ensureDriveDir } from './settings' import { db, upsertDevice, listDevices, listMessages, insertMessage, getMessage, updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread, getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates, setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation, recordAudit, listAudit, pruneAudit } from './db' import { Discovery, listNetInterfaces, type NetInterface } from './discovery' import { ChatServer } from './chat-server' import { ChatClient } from './chat-client' import { FileServer, uploadFile, uploadBuffer } from './file-server' import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive' import { scanSync, applySyncItems, type SyncApplyItem } from './sync' import { RelationStore, type Grant } from './relations' import { ManifestStore } from './sync-manifest' import { AutoSync } from './auto-sync' import { RelationService } from './relation-service' import { broadcastToRenderer, focusMainWindow } from './window' import { rebuildMenu } from './tray' import { notify, setBadgeCount } from './notify' import { TerminalManager } from './remote/terminal' import { ForwardManager } from './remote/forward' import { UsbBridgeManager } from './remote/usb' import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval' import { computeWsState } from './ws-state' import { makeThumb, makeThumbFromFile, makeThumbFromBuffer, copyToClipboard, readImageDataUrl } from './image' import type { ImageSource } from './image-core' import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol' import { DEFAULT_PORTS } from './protocol' interface NetCtx { self: DeviceInfo discovery: Discovery chatServer: ChatServer chatClient: ChatClient fileServer: FileServer terminal: TerminalManager forward: ForwardManager usb: UsbBridgeManager relations: RelationStore manifestStore: ManifestStore relationService: RelationService /** 自动同步 (在 bindNetworkContext 里创建) */ autoSync?: AutoSync } let ctx: NetCtx | null = null // 收集所有对端的"消息通道"状态 export function collectWsState(): Record { if (!ctx) return {} return computeWsState( ctx.discovery.getPeers().map(p => p.deviceId), ctx.chatClient.getStatus(), ctx.chatServer, ) } // 对端是否还"在线": discovery 能看到它, 或还有任一条 WS 通道活着 function isPeerReachable(deviceId: string): boolean { if (!ctx) return false if (ctx.chatServer.isConnected(deviceId)) return true if (ctx.chatClient.getStatus()[deviceId] === 'open') return true return ctx.discovery.getPeers().some(p => p.deviceId === deviceId) } // 广播 WS 通道状态全量快照 // 用"全量快照"而不是"单个设备的增量事件": 增量事件在渲染层还没订阅时会被丢弃, // 启动阶段主进程往往先连上对端 (index.ts 里 connectTo 早于 createMainWindow), // 渲染层 mount 后就再也收不到那条 open, UI 会永久停在"等待重连"。 function broadcastWsState() { broadcastToRenderer('device:wsState', { ws: collectWsState() }) } // 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer // 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息 // 气泡留在 receiving 状态, 让用户右键手动删 const PENDING_FILE_TIMEOUT_MS = 60_000 const pendingMetaTimers = new Map() function armPendingMetaTimer(fileId: string, messageId: string, fromDeviceId: string) { disarmPendingMetaTimer(fileId) const timer = setTimeout(() => { pendingMetaTimers.delete(fileId) console.warn(`[ipc] file ${fileId.slice(0, 8)} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s; bubble stays for user to delete`) // 通知 sender 标记失败 (sender 在线时能把 status='sent' 升级到 'failed') ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId) }, PENDING_FILE_TIMEOUT_MS) pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId }) } function disarmPendingMetaTimer(fileId: string) { const t = pendingMetaTimers.get(fileId) if (t) { clearTimeout(t.timer) pendingMetaTimers.delete(fileId) } } /** * 找一个对端 (优先用在线的 discovery 信息, 离线时退回设备表)。 * 有关系时把 driveToken 换成关系签发的 per-peer 令牌。 */ function findPeer(peerId: string): DeviceInfo | null { const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId) if (p) { // 有关系时用关系签发的 per-peer 令牌 (广播包里已经不再带全局 driveToken 了) const rel = ctx?.relations.getActiveRelationByPeer(peerId) if (rel?.peerToken) return { ...(p as DeviceInfo), driveToken: rel.peerToken } return p as DeviceInfo } const d = listDevices().find(x => x.device_id === peerId) as any if (!d) return null return { deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform, version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1', chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen, driveToken: d.drive_token || undefined, } as DeviceInfo } // 关系对渲染层的视图: **剥掉令牌** (界面不需要, 也不该拿到凭据) function publicRelation(r: any) { if (!r) return r const { myToken, peerToken, ...rest } = r return rest } // 关系帧路由: 建立 / 接受 / 拒绝 (两个方向都走这里) // 返回 true = 这个帧已经处理完了, 调用方不用再管 function routeRelationFrame(peer: DeviceInfo, frame: WsFrame): boolean { if (frame.type === 'relation:request') { ctx?.relationService.handleRelationRequest(peer, frame.payload) .catch((e) => console.warn('[relation] handle request error:', e?.message)) return true } if (frame.type === 'relation:accept') { ctx?.relationService.handleRelationAccept(peer, frame.payload); return true } if (frame.type === 'relation:reject') { ctx?.relationService.handleRelationReject(peer, frame.payload); return true } if (frame.type === 'relation:revoked') { ctx?.relationService.handleRelationRevoked(peer, frame.payload.relationId); return true } return false } export function bindNetworkContext(c: NetCtx) { ctx = c // 自动同步: 关系开了开关就定时跑一轮 (冲突跳过, 不静默覆盖) c.autoSync = new AutoSync({ relations: c.relations, manifestStore: c.manifestStore, findPeer: (peerId) => findPeer(peerId), notify: (event, payload) => broadcastToRenderer(event, payload), }) c.autoSync.start() // 网络事件 -> 渲染进程 c.discovery.on('found', (d) => { // 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连 if (getDevice(d.deviceId)?.ignored) return upsertDevice({ device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0, drive_token: d.driveToken || null, }) broadcastToRenderer('device:found', d) c.chatClient.connectTo(d) }) c.discovery.on('updated', (d: DeviceInfo) => { if (getDevice(d.deviceId)?.ignored) return upsertDevice({ device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0, drive_token: d.driveToken || null, }) broadcastToRenderer('device:updated', d) c.chatClient.connectTo(d) }) c.discovery.on('lost', (id) => { if (getDevice(id)?.ignored) return broadcastToRenderer('device:lost', { deviceId: id }) }) c.chatServer.on('message', (from, env) => { // 同一 messageId 可能来 2 次: // 1) 元数据帧 (savedPath=null, 文件正在被上传) -> 气泡先占位 // 2) 完成帧 (savedPath 已填, 上传校验通过) -> 气泡出"打开/位置" // 用 upsert 处理: 已存在就更新 body, 不存在就插入 const existing = getMessage(env.messageId) if (existing) { // 第二次帧: 合并 body (主要是 fill savedPath / hash) updateMessageBody(env.messageId, env.body as any) const merged = { ...JSON.parse(existing.body_json), ...(env.body as any) } // 文件已落盘 -> 接收完成 const wasReady = existing.status === 'delivered' if (!wasReady && (env.body as any).savedPath) { updateMessageStatus(env.messageId, 'delivered') incrUnread(env.fromDeviceId) updateBadge() notifyIfNeeded(from, env) } broadcastToRenderer('message:received', { ...env, body: merged, status: wasReady ? existing.status : 'delivered' }) } else { // 第一次帧: 新插入 const fileOrImage = env.type === 'file' || env.type === 'image' const ready = fileOrImage ? Boolean((env.body as any).savedPath) : true insertMessage({ messageId: env.messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId, ts: env.ts, body: env.body, status: ready ? 'delivered' : 'receiving', }) broadcastToRenderer('message:received', env) // 已落盘 -> 算接收完成: bump unread + notify + ack if (ready) { incrUnread(env.fromDeviceId) updateBadge() notifyIfNeeded(from, env) } else { // 元数据先到, 文件本体还没到 -> 期待 timer const fid = (env.body as any)?.fileId if (fid) armPendingMetaTimer(fid, env.messageId, env.fromDeviceId) } } // ACK 只在 ready 时回 (waiting 没意义, 还会让 sender 把状态过早升级 delivered) const ready = env.type === 'text' || env.type === 'system' || Boolean((env.body as any).savedPath) if (ready) { ctx!.chatClient.send( { type: 'ack', messageId: env.messageId, status: 'delivered' }, from.deviceId ) } else { // 还在传 -> 回个 "received" 给 sender, 让它把状态从 sending -> sent ctx!.chatClient.send( { type: 'ack', messageId: env.messageId, status: 'sent' }, from.deviceId ) } }) // chat-client 收到的所有 ws 帧 (含 ack / hello / 未来的 read 等) c.chatClient.on('message', (peer, frame) => { if (frame.type === 'ack') { const messageId = frame.messageId if (!messageId) return const row = getMessage(messageId) if (!row) return if (row.from_id !== ctx!.self.deviceId) return if (row.to_id !== peer.deviceId) return if (row.status === 'delivered' || row.status === 'read') return let nextStatus: string if (frame.status === 'read') nextStatus = 'read' else if (frame.status === 'sent') nextStatus = 'sent' 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 } if (routeRelationFrame(peer, frame)) 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 } if (routeRelationFrame(peer, frame)) return }) c.chatServer.on('connect', (_ws, peer) => { // 先推通道状态再推在线事件: 渲染层收到 "offline" 时会查通道状态决定要不要真标离线, // 顺序反了它读到的是旧快照, 会把刚断开的设备继续留在"在线" broadcastWsState() broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address }) // 对端上线 -> 触发离线消息 flush flushPendingFor(peer.deviceId) // 对端上线 -> 立刻试一次同步 (不用等下一个轮询周期) c.autoSync?.trigger() }) // chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline // 重连由 chat-client 后台 backoff 自动处理, 对用户透明 // 但 error 事件必须挂监听, 否则 Node.js 抛 ERR_UNHANDLED_ERROR 杀进程 c.chatClient.on('error', (peer, err) => { console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`) }) // 我们这端 outgoing WS 接通 / 断开 — 都要广播给 renderer, 让 UI 能区分 // "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路, // 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推) c.chatClient.on('open', (peer) => { broadcastWsState() flushPendingFor(peer.deviceId) }) c.chatClient.on('close', (peer) => { broadcastWsState() // 两条腿都断了, discovery 也不认识它了 -> 确实离线, 不用等 discovery 的 15s 超时 if (!isPeerReachable(peer.deviceId)) { broadcastToRenderer('device:offline', { deviceId: peer.deviceId }) } }) c.chatServer.on('disconnect', (id) => { // 先推通道状态再推离线事件 (理由同 'connect') broadcastWsState() broadcastToRenderer('device:offline', { deviceId: id }) }) c.chatServer.on('typing', (from) => { broadcastToRenderer('message:typing', { fromDeviceId: from.deviceId }) }) // 接收端 HTTP 上传进度 -> 广播给本机 renderer (接收方的气泡显示进度) c.fileServer.on('progress', (info: { fileId: string; sent: number; total: number; fromAddr: string }) => { // 任何进度都说明文件确实在传, 取消期待 timer disarmPendingMetaTimer(info.fileId) broadcastToRenderer('message:progress', { toDeviceId: '', fileId: info.fileId, sent: info.sent, total: info.total, direction: 'recv' }) }) c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => { disarmPendingMetaTimer(info.fileId) }) // 对端从我的网盘下载了文件 -> 本机提示 + 审计 c.fileServer.on('driveDownloaded', (info: { name: string; size: number; relPath: string; fromAddr: string }) => { const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr) const who = peer ? peer.name : info.fromAddr || '某台设备' recordAudit({ action: 'drive.download', source: peer?.deviceId || info.fromAddr, result: 'ok', payload: { name: info.name, size: info.size, path: info.relPath }, }) broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who }) }) // 对端往我的网盘上传了文件 (文件夹同步的上行) -> 本机提示 + 审计 c.fileServer.on('driveUploaded', (info: { relPath: string; size: number; fromAddr: string }) => { const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr) const who = peer ? peer.name : info.fromAddr || '某台设备' recordAudit({ action: 'drive.upload', source: peer?.deviceId || info.fromAddr, result: 'ok', payload: { size: info.size, path: info.relPath }, }) broadcastToRenderer('drive:inbound', { name: basename(info.relPath), size: info.size, peerId: peer?.deviceId, peerName: who }) }) // 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示 c.chatServer.on('recall', (from, messageId) => { const row = getMessage(messageId) if (!row) return let savedPath: string | undefined try { savedPath = (JSON.parse(row.body_json) as any)?.savedPath } catch {} deleteMessage(messageId) if (savedPath) { fsp.unlink(savedPath).catch((e) => { if (e?.code !== 'ENOENT') console.warn(`[recall] unlink ${savedPath} failed: ${e.message}`) }) } broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) }) // 已读回执: 对端说"我看到 upTo 了", 把我方所有 from=self, to=peer, ts<=read.ts, status='delivered' 的消息升 'read' c.chatServer.on('read', (from, upToMessageId) => { if (!ctx) return const row = getMessage(upToMessageId) if (!row) return const upToTs = row.ts // 只升级 delivered -> read, sent 留给 ack 处理 const stmt = db.prepare(`UPDATE messages SET status = 'read' WHERE from_id = ? AND to_id = ? AND status = 'delivered' AND ts <= ?`) stmt.run(ctx.self.deviceId, from.deviceId, upToTs) // 把这次升级告诉 renderer (逐条 statusChanged, 复用现有渲染层逻辑) const updated = db.prepare(`SELECT message_id FROM messages WHERE from_id = ? AND to_id = ? AND ts <= ? AND status = 'read'`).all(ctx.self.deviceId, from.deviceId, upToTs) as Array<{ message_id: string }> for (const r of updated) { 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) { if (env.type === 'system') return if (env.fromDeviceId === ctx?.self.deviceId) return const w = BrowserWindow.getAllWindows()[0] if (w && w.isFocused()) return let title = from.name let body = '' if (env.type === 'text') body = (env.body as TextMessage).content.slice(0, 80) else if (env.type === 'image') body = '[图片]' else if (env.type === 'file') body = `[文件] ${(env.body as FileMessage).name}` if (!body) return notify({ title, body, onClick: () => { focusMainWindow(); broadcastToRenderer('message:focus', { fromDeviceId: from.deviceId }) } }) } function updateBadge() { setBadgeCount(totalUnread()) } // ===== 业务动作 ===== async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; reason?: string; status?: string }> { if (!ctx) return { ok: false, reason: 'no ctx' } // 1. 落库 (先按 pending 入库, 发送成功改 sent) insertMessage({ messageId: env.messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId, ts: env.ts, body: env.body, status: 'pending', }) // 2. 通过 ws 发送 const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId) if (!sent) { // WS 没开 — 消息已在 DB 里 status='pending', flushPendingFor 在对端 outgoing WS 接通时会自动重发. // 不当作 "失败" 报给前端, 避免误导 (因为对方 UDP 可能还在, 只是 WS 抖了). // 副作用: 渲染端的 "待对方上线" 气泡/重试按钮照常显示; 不再弹 "发送失败" toast. return { ok: true, status: 'pending' } } // 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck) updateMessageStatus(env.messageId, 'sent') // 注意: 这里不广播 statusChanged. 由 IPC handler 拿到 status 后通过 message:local 一次性发给 renderer, // 避免 race (statusChanged 先到, 消息还没进 store, updateStatus 找不到) return { ok: true, status: 'sent' } } // 对端上线 / 连接建立后, 把缓存的待发消息重试一次 async function flushPendingFor(peerId: string) { if (!ctx) return const rows = listPendingMessages(peerId, ctx.self.deviceId) let count = 0 // 1) 队列里的 pending 消息 (完全没发出去过的) for (const row of rows) { const body = JSON.parse(row.body_json) const env: MessageEnvelope = { messageId: row.message_id, type: row.type as any, fromDeviceId: row.from_id, toDeviceId: row.to_id, ts: row.ts, body, } const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId) if (sent) { updateMessageStatus(env.messageId, 'sent') broadcastToRenderer('message:statusChanged', { messageId: env.messageId, status: 'sent' }) count++ } else { // 仍未连上, 留给下次重试 break } } // 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent') // 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置" // 注意: rows 上面已经被 status='pending' 过滤, 这里必须重新查 status='sent' + savedPath 已填的文件 const updateRows = listSentFileUpdates(ctx.self.deviceId, peerId) for (const row of updateRows) { const body = JSON.parse(row.body_json) const env: MessageEnvelope = { messageId: row.message_id, type: row.type as any, fromDeviceId: row.from_id, toDeviceId: row.to_id, ts: row.ts, body, } const sentUpdate = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId) if (sentUpdate) { // 触发 renderer 同步一下 body (保险 — 让对端的 uploaded bubble 也再被 forced update) broadcastToRenderer('message:statusChanged', { messageId: row.message_id, status: 'sent' }) count++ } } if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`) } // 启动时也尝试一次 flush (万一启动时对方刚好就绪) setTimeout(() => { if (!ctx) return for (const row of listAllPending(ctx.self.deviceId)) { if (ctx.chatClient.isOpen(row.to_id)) { flushPendingFor(row.to_id) } } }, 1500) export function registerIpc() { // ---- 设备 / 自身 ---- ipcMain.handle('self:info', () => ctx?.self) // 渲染层启动时拉一次当前状态 (补上启动阶段被丢弃的事件) ipcMain.handle('app:state', () => ({ ws: collectWsState(), })) ipcMain.handle('device:list', () => { const rows = listDevices() const online = new Set(ctx?.discovery.getPeers().map(p => p.deviceId) || []) return rows.map(r => ({ deviceId: r.device_id, name: r.name, hostname: r.hostname || '', platform: r.platform || '', appVersion: r.app_version || '', address: r.last_ip || '', online: online.has(r.device_id), lastSeen: r.last_seen, })) }) ipcMain.handle('device:triggerScan', () => { // 触发一次密集广播 (通过重启 discovery timer 的快相) if (!ctx) return false ;(ctx.discovery as any).fastPhaseUntil = Date.now() + 3_000 return true }) // 删除设备: 标记 ignored=1, 停掉后台重连, 从 sidebar 消失 // UDP 后续再发现也不会重新弹 (discovery 上游已过滤 ignored) ipcMain.handle('device:delete', (_e, deviceId: string) => { if (!ctx) return { ok: false, reason: 'no ctx' } if (deviceId === ctx.self.deviceId) return { ok: false, reason: '不能删除本机' } setDeviceIgnored(deviceId, true) ctx.chatClient.remove(deviceId) // 停掉 backoff 重连 ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除 broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉 // 清掉对应未读 (顺手刷一次任务栏角标 —— 少了这步, 删了设备角标数字还挂着) clearUnread(deviceId) updateBadge() return { ok: true } }) // 删除单条消息 (本地) — 不广播, 不撤回对端 // opts.deleteFile: 同时删掉磁盘上的文件 (按 msg.body.savedPath) // 返回 { ok, deletedFile? } 让 UI 在弹框里展示真实路径 ipcMain.handle('message:delete', async (_e, messageId: string, opts?: { deleteFile?: boolean }) => { const row = getMessage(messageId) if (!row) return { ok: false, reason: '消息不存在' } let deletedFile: string | undefined if (opts?.deleteFile) { const body = (() => { try { return JSON.parse(row.body_json) } catch { return null } })() const filePath: string | undefined = body?.savedPath if (filePath) { try { await fsp.unlink(filePath) deletedFile = filePath } catch (e: any) { // 文件不在磁盘上 (用户手动删了/换盘) 不视为失败 — 把消息也删了 if (e?.code !== 'ENOENT') return { ok: false, reason: `删除文件失败: ${e?.message || e}` } } } } deleteMessage(messageId) broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) return { ok: true, deletedFile } }) // 已读回执: 本机在 active 对话里看到了新消息, 告诉对端把 upTo (含) 之前的消息标 'read' ipcMain.handle('message:markRead', (_e, args: { peerId: string; upToMessageId: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } if (!args?.peerId || !args?.upToMessageId) return { ok: false, reason: '参数缺失' } const sent = ctx.chatClient.send( { type: 'read', upTo: args.upToMessageId, ts: Date.now() }, args.peerId ) return { ok: sent, reason: sent ? undefined : '对方离线' } }) // ---- 消息 ---- ipcMain.handle('message:list', (_e, peerId: string) => { if (!ctx) return [] const rows = listMessages(peerId, ctx.self.deviceId, 500) return rows.map(rowToEnvelope) }) ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string; replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const env: MessageEnvelope = { messageId: randomUUID(), type: 'text', fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, ts: Date.now(), body: { type: 'text', messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, content: args.content, ...(args.replyTo ? { replyTo: args.replyTo } : {}), } as TextMessage, } ;(env.body as TextMessage).messageId = env.messageId ;(env.body as TextMessage).ts = env.ts ;(env.body as TextMessage).fromDeviceId = env.fromDeviceId ;(env.body as TextMessage).toDeviceId = env.toDeviceId const r = await deliverAndStore(env) broadcastToRenderer('message:local', { ...env, status: r.status || 'pending' }) return r }) // 上传文件到对端 (从渲染进程拿到本地路径) -> 发送 file/image 类型消息 // 流程: 立刻广播气泡 + 发 WS 元数据 (让对端秒显示) -> 后台上传 -> 完事后回填 savedPath + 再发 WS 更新 ipcMain.handle('message:sendFile', async (_e, args: { toDeviceId: string localPath: string name?: string mime?: string asImage?: boolean onProgress?: boolean replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) || listDevices().find(d => d.device_id === args.toDeviceId) as any if (!peer) return { ok: false, reason: 'peer not found' } if (!existsSync(args.localPath)) return { ok: false, reason: 'file not exist' } const stat = statSync(args.localPath) const name = args.name || basename(args.localPath) const mime = args.mime || 'application/octet-stream' const fileId = randomUUID() const messageId = randomUUID() const peerInfo: any = peer // 图片: 现场生成缩略图塞进消息体 —— 发送方生成一次, 对方直接拿来显示, // 不用在对端再解码一遍 (也顺带让"老版本对端"能显示)。 const thumbDataUrl = args.asImage ? makeThumbFromFile(args.localPath) : undefined // 1. 构造初始 envelope (savedPath=null, hash=null) - 气泡先占位, 进度条 0% const baseBody = { type: args.asImage ? 'image' : 'file', messageId, ts: Date.now(), fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, fileId, name, size: stat.size, mime, savedPath: null as string | null, hash: null as string | null, localPath: args.localPath, // retry 时复用 ...(thumbDataUrl ? { thumbDataUrl } : {}), ...(args.replyTo ? { replyTo: args.replyTo } : {}), } const env: MessageEnvelope = { messageId, type: args.asImage ? 'image' : 'file', fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, ts: Date.now(), body: baseBody as any, } // 2. 落库 + 广播 (sender 气泡立刻出现, status='sending') const initialStatus = ctx.chatClient.isOpen(args.toDeviceId) ? 'sending' : 'pending' insertMessage({ messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId, ts: env.ts, body: env.body, status: initialStatus, }) broadcastToRenderer('message:local', { ...env, status: initialStatus }) // 3. 如果对端在线, 立刻发 WS metadata (让对端秒显示气泡) if (initialStatus === 'sending') { const sentMeta = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, args.toDeviceId) if (sentMeta) { updateMessageStatus(messageId, 'sent') broadcastToRenderer('message:statusChanged', { messageId, status: 'sent' }) } else { // 一瞬间连不上 -> 退回 pending, 等 flush updateMessageStatus(messageId, 'pending') broadcastToRenderer('message:statusChanged', { messageId, status: 'pending' }) } } // 4. 立刻返回 renderer (不等上传, 几百 ms 内必到) const ackP: { ok: boolean; reason?: string; status?: string; messageId: string } = { ok: true, messageId, status: initialStatus, } // 5. 后台跑上传 + 完成后回填 ;(async () => { const host = peerInfo.address || peerInfo.last_ip const uploadResult = await uploadFile({ host, port: peerInfo.filePort, fileId, name, size: stat.size, mime, filePath: args.localPath, onProgress: (sent, total) => { broadcastToRenderer('message:progress', { toDeviceId: args.toDeviceId, fileId, sent, total }) }, }) if (!uploadResult.ok) { updateMessageStatus(messageId, 'failed') broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' }) return } // 完整性对账: receiver-hash 是 server 返回的 (接收端流式 hash), sender-hash 是 uploadResult.hash // (sender 上传时一起算的). 不匹配 -> TCP 出 bug 了, 极小概率, warn 即可 const receiverHash = (uploadResult as any).hash if (receiverHash && uploadResult.hash && receiverHash !== uploadResult.hash) { console.warn(`[sendFile] hash mismatch (sender=${uploadResult.hash.slice(0, 8)}, receiver=${receiverHash.slice(0, 8)})`) } // 回填 savedPath + hash 到消息体 (sender 那边气泡获得"打开/位置") updateMessageBody(messageId, { savedPath: uploadResult.savedPath, hash: uploadResult.hash }) const filledEnv: MessageEnvelope = { ...env, body: { ...baseBody, savedPath: uploadResult.savedPath, hash: uploadResult.hash } as any, } broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) // 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开") // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发 const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId) ? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) : false if (!sentUpdate) { console.warn(`[sendFile] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`) } // 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered void fileId // suppress unused warning })().catch((e: any) => { console.warn('[sendFile] background upload failed:', e?.message) updateMessageStatus(messageId, 'failed') broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' }) }) return ackP }) // 上传剪贴板/前端拿到的 Buffer (粘贴图片) — 同样: 先广播气泡, 后台上传 ipcMain.handle('message:sendBuffer', async (_e, args: { toDeviceId: string dataBase64: string name: string mime: string replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) || listDevices().find(d => d.device_id === args.toDeviceId) as any if (!peer) return { ok: false, reason: 'peer not found' } const peerInfo: any = peer const buf = Buffer.from(args.dataBase64, 'base64') const fileId = randomUUID() const messageId = randomUUID() // 缩略图: 小图直接用原字节 (省一次重编码), 大图现生成缩略图 —— // 以前大图不带缩略图, 气泡只能显示成"[图片] xxx.png"的文件卡片。 const thumbDataUrl = buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : makeThumbFromBuffer(buf) const baseBody: ImageMessage = { type: 'image', messageId, ts: Date.now(), fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, fileId, name: args.name, size: buf.length, mime: args.mime, thumbDataUrl, dataBase64: args.dataBase64, ...(args.replyTo ? { replyTo: args.replyTo } : {}), } const env: MessageEnvelope = { messageId, type: 'image', fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, ts: Date.now(), body: baseBody, } const initialStatus = ctx.chatClient.isOpen(args.toDeviceId) ? 'sending' : 'pending' insertMessage({ messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId, ts: env.ts, body: env.body, status: initialStatus, }) broadcastToRenderer('message:local', { ...env, status: initialStatus }) if (initialStatus === 'sending') { const sentMeta = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, args.toDeviceId) if (sentMeta) { updateMessageStatus(messageId, 'sent') broadcastToRenderer('message:statusChanged', { messageId, status: 'sent' }) } else { updateMessageStatus(messageId, 'pending') broadcastToRenderer('message:statusChanged', { messageId, status: 'pending' }) } } const ackP: { ok: boolean; reason?: string; status?: string; messageId: string } = { ok: true, messageId, status: initialStatus, } ;(async () => { const host = peerInfo.address || peerInfo.last_ip const uploadResult = await uploadBuffer({ host, port: peerInfo.filePort, fileId, name: args.name, mime: args.mime, data: buf, onProgress: (sent, total) => { broadcastToRenderer('message:progress', { toDeviceId: args.toDeviceId, fileId, sent, total }) }, }) if (!uploadResult.ok) { updateMessageStatus(messageId, 'failed') broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' }) return } updateMessageBody(messageId, { savedPath: uploadResult.savedPath, hash: uploadResult.hash }) const filledEnv: MessageEnvelope = { ...env, body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any }, } broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发 const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId) ? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) : false if (!sentUpdate) { console.warn(`[sendBuffer] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`) } void fileId })().catch((e: any) => { console.warn('[sendBuffer] background upload failed:', e?.message) updateMessageStatus(messageId, 'failed') broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' }) }) return ackP }) ipcMain.handle('message:recall', async (_e, messageId: string) => { if (!ctx) return { ok: false } const row = getMessage(messageId) if (!row) return { ok: false, reason: '消息不存在' } if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: '只能撤回自己发的消息' } // 微信式 2 分钟时限 const RECALL_LIMIT_MS = 2 * 60_000 if (Date.now() - row.ts > RECALL_LIMIT_MS) { const ageMin = Math.round((Date.now() - row.ts) / 60_000) return { ok: false, reason: `超过 2 分钟无法撤回 (已 ${ageMin} 分钟)` } } // 删除本地 deleteMessage(messageId) // 通知对端 (不传 savedPath 让对端从 DB 自行取 — 这里的 row 还在; 但保险起见让对端查自己的 DB) ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id) broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) return { ok: true } }) // 手动触发: 对所有当前已连接的对端 flush 一次 pending ipcMain.handle('message:flushPending', async () => { if (!ctx) return { ok: false, flushed: 0 } let totalFlushed = 0 // 收集所有 to_id 集合 const targets = new Set() for (const row of listAllPending(ctx.self.deviceId)) targets.add(row.to_id) for (const peerId of targets) { const before = listPendingMessages(peerId, ctx.self.deviceId).length await flushPendingFor(peerId) const after = listPendingMessages(peerId, ctx.self.deviceId).length totalFlushed += before - after } return { ok: true, flushed: totalFlushed } }) // 手动重发某条消息 (status 任意 -> pending -> 尝试发送) ipcMain.handle('message:retry', async (_e, messageId: string) => { if (!ctx) return { ok: false, reason: 'no ctx' } const row = getMessage(messageId) if (!row) return { ok: false, reason: 'no message' } if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' } const body = JSON.parse(row.body_json) const setStatus = (status: string) => { updateMessageStatus(messageId, status) broadcastToRenderer('message:statusChanged', { messageId, status }) } // 检查对端连接 const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id) if (!peer) { setStatus('pending') return { ok: false, reason: '对方设备不在线', status: 'pending', wsDown: true } } if (!ctx.chatClient.isOpen(row.to_id)) { setStatus('pending') return { ok: false, reason: '等待连接恢复', status: 'pending' } } // 根据类型处理 if (row.type === 'text') { setStatus('sending') const env: MessageEnvelope = { messageId: row.message_id, type: 'text', fromDeviceId: row.from_id, toDeviceId: row.to_id, ts: row.ts, body, } const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, row.to_id) if (sent) { setStatus('sent') return { ok: true, status: 'sent' } } setStatus('pending') return { ok: false, reason: 'send failed', status: 'pending' } } if (row.type === 'file' || row.type === 'image') { const localPath = body.localPath as string | undefined const dataBase64 = body.dataBase64 as string | undefined if (!localPath && !dataBase64) { return { ok: false, reason: 'no local file' } } setStatus('sending') const fileId = body.fileId const name = body.name const size = body.size const mime = body.mime let uploadResult if (localPath) { uploadResult = await uploadFile({ host: peer.address, port: peer.filePort, fileId, name, size, mime, filePath: localPath, }) } else { const buf = Buffer.from(dataBase64!, 'base64') uploadResult = await uploadBuffer({ host: peer.address, port: peer.filePort, fileId, name, mime, data: buf, }) } if (!uploadResult.ok) { setStatus('failed') return { ok: false, reason: uploadResult.error || 'upload failed', status: 'failed' } } // 重新组装消息 const env: MessageEnvelope = { messageId: row.message_id, type: row.type as any, fromDeviceId: row.from_id, toDeviceId: row.to_id, ts: row.ts, body: { ...body, savedPath: uploadResult.savedPath }, } const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, row.to_id) if (sent) { setStatus('sent') // 持久化最新的 savedPath const newBodyJson = JSON.stringify({ ...body, savedPath: uploadResult.savedPath }) db.prepare(`UPDATE messages SET body_json = ? WHERE message_id = ?`).run(newBodyJson, messageId) return { ok: true, status: 'sent' } } setStatus('pending') return { ok: false, reason: 'send failed', status: 'pending' } } return { ok: false, reason: 'unsupported type' } }) ipcMain.handle('message:typing', (_e, toDeviceId: string) => { if (!ctx) return ctx.chatClient.send({ type: 'typing', from: ctx.self.deviceId, ts: Date.now() }, toDeviceId) }) // ---- 未读 ---- ipcMain.handle('unread:list', () => getUnread()) ipcMain.handle('unread:clear', (_e, deviceId: string) => { clearUnread(deviceId) updateBadge() return true }) // ---- 设置 ---- ipcMain.handle('settings:get', () => getSettings()) ipcMain.handle('settings:set', (_e, patch: any) => { const ns = updateSettings(patch) ensureDownloadDir() if (patch.deviceName) { ctx!.self.name = patch.deviceName } if ('autoStart' in patch && app.isPackaged) { try { app.setLoginItemSettings({ openAtLogin: !!patch.autoStart, path: process.execPath }) } catch (e: any) { console.warn('[settings:set] setLoginItemSettings failed:', e?.message) } } rebuildMenu() broadcastToRenderer('settings:changed', ns) return ns }) ipcMain.handle('settings:chooseDownloadDir', async () => { const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] if (!w) return null const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'] }) if (r.canceled || !r.filePaths[0]) return null updateSettings({ downloadDir: r.filePaths[0] }) ensureDownloadDir() rebuildMenu() broadcastToRenderer('settings:changed', getSettings()) return r.filePaths[0] }) // ---- 文件操作 ---- ipcMain.handle('file:openInFolder', async (_e, p: string) => { if (!p) return false try { await shell.showItemInFolder(p) return true } catch { return false } }) ipcMain.handle('file:open', async (_e, p: string) => { if (!p) return false try { const err = await shell.openPath(p) return !err } catch { return false } }) ipcMain.handle('file:readBase64', async (_e, p: string) => { try { const buf = await fsp.readFile(p) return buf.toString('base64') } catch { return null } }) // 通过 dialog 让用户选文件 (发送用) ipcMain.handle('file:pick', async (_e, opts?: { image?: boolean }) => { const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] if (!w) return null const r = await dialog.showOpenDialog(w, { properties: ['openFile'], filters: opts?.image ? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }] : undefined, }) if (r.canceled || !r.filePaths[0]) return null const p = r.filePaths[0] const st = statSync(p) return { path: p, name: basename(p), size: st.size } }) // ===== 图片: 缩略图 / 复制 / 看原图 ===== // // 消息体里的 thumbDataUrl 是"发送方生成、双方共用"的缩略图。 // 但老消息 (落库时还没有缩略图) 和老版本对端发来的消息都没有它 —— // 这时渲染层会退到 image:thumb 现场生成。 // 现场生成缩略图 (渲染层懒加载兜底) ipcMain.handle('image:thumb', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => { try { const src: ImageSource = args.path ? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 } : { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 } const dataUrl = makeThumb(src) if (!dataUrl) return { ok: false, reason: '无法生成缩略图' } return { ok: true, dataUrl } } catch (e: any) { return { ok: false, reason: String(e?.message || e) } } }) // 右键"复制图片" —— 写的是原图, 不是缩略图 ipcMain.handle('image:copy', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => { try { const src: ImageSource = args.path ? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 } : { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 } return copyToClipboard(src) } catch (e: any) { return { ok: false, reason: String(e?.message || e) } } }) // 点开看大图 —— 读本地原图成 data URL (气泡里是缩略图) ipcMain.handle('image:dataUrl', async (_e, args: { path?: string }) => { if (!args?.path) return { ok: false, reason: '没有本地文件' } return readImageDataUrl(args.path) }) // ===== 网盘 (浏览对方共享目录 + 选文件下载) ===== // 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录 ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备' } if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' } return listDrive(peer, args.path || '') }) ipcMain.handle('drive:root', async (_e, args: { peerId: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备' } if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' } return getDriveRoot(peer) }) // 下载选中的文件/目录 (顺序下载, 逐个报进度) ipcMain.handle('drive:download', async (_e, args: { peerId: string; paths: string[]; destDir?: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备' } const destDir = args.destDir || ensureDownloadDir() const results: { relPath: string; ok: boolean; savedPath?: string; size?: number; reason?: string }[] = [] let index = 0 const total = (args.paths || []).length for (const relPath of args.paths || []) { // 选中的是目录 -> 递归收集里面的文件, 保持层级下载 const collected = await collectDriveFiles(peer, relPath) if (!collected.ok) { results.push({ relPath, ok: false, reason: collected.reason }) continue } // 目录本身也存一份 (下载整目录时, 以目录名作为本地子目录) const isDir = collected.files.length > 0 && collected.files[0].relPath !== relPath const baseName = basename(relPath) for (const f of collected.files) { // 相对目录的层级 (去掉目录名前缀), 只取目录部分, 文件名由下载时自己拼 const relInside = isDir ? f.relPath.slice(relPath.length).replace(/^[/\\]+/, '') : '' const dirInside = relInside ? dirname(relInside) : '' const destSubPath = isDir ? (dirInside && dirInside !== '.' ? join(baseName, dirInside) : baseName) : undefined const r = await downloadDriveFile({ peer, relPath: f.relPath, destDir, destSubPath: destSubPath || undefined, onProgress: (sent, totalBytes) => { broadcastToRenderer('drive:progress', { peerId: args.peerId, relPath: f.relPath, sent, total: totalBytes, index, count: total }) }, }) results.push({ relPath: f.relPath, ok: r.ok, savedPath: r.path, size: r.size, reason: r.reason }) index++ } if (!isDir) index++ } const okCount = results.filter(r => r.ok).length recordAudit({ action: 'drive.download', target: args.peerId, result: okCount === results.length && results.length > 0 ? 'ok' : 'error', payload: { count: results.length, ok: okCount, files: results.map(r => r.relPath) }, }) return { ok: true, results } }) // 打开本机网盘目录 ipcMain.handle('drive:openFolder', async () => { try { await shell.openPath(ensureDriveDir()) return true } catch { return false } }) // 换网盘根目录 ipcMain.handle('drive:pickDir', async () => { const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] if (!w) return null const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'] }) if (r.canceled || !r.filePaths[0]) return null updateSettings({ driveDir: r.filePaths[0] }) ensureDriveDir() rebuildMenu() broadcastToRenderer('settings:changed', getSettings()) return r.filePaths[0] }) // ===== 文件夹同步 (本地目录 <-> 对方网盘目录) ===== ipcMain.handle('sync:pickLocalDir', async () => { const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] if (!w) return null const cur = getSettings().syncLocalDir const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'], defaultPath: cur && existsSync(cur) ? cur : undefined, }) if (r.canceled || !r.filePaths[0]) return null return r.filePaths[0] }) // 扫描两边, 生成变更列表 (只读磁盘; 关系表会被更新, 但那只是记录"我这边变了") ipcMain.handle('sync:scan', async (_e, args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备' } const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined // 有关系就用关系签发的 per-peer 令牌; 没有关系退回旧的全局 token (老版本对端) let token = '' if (rel && rel.state === 'active') { if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系' } token = rel.peerToken } else { if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' } token = peer.driveToken } // 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录) const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir) if (!localDir) return { ok: false, reason: '还没选本地目录' } const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? '')) const r = await scanSync({ peer, localDir, remotePath, token, relationId: rel?.relationId, store: ctx.manifestStore, selfDeviceId: ctx.self.deviceId, }) if (r.ok && !rel) { updateSettings({ syncLocalDir: r.localDir, syncRemotePath: r.remotePath }) } return r }) // 执行用户勾选的同步项 ipcMain.handle('sync:apply', async (_e, args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string; items: SyncApplyItem[] }) => { if (!ctx) return { ok: false, reason: 'no ctx', results: [], done: 0, failed: 0 } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备', results: [], done: 0, failed: 0 } const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined let token = '' if (rel && rel.state === 'active') { if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系', results: [], done: 0, failed: 0 } token = rel.peerToken } else { if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系', results: [], done: 0, failed: 0 } token = peer.driveToken } // 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录) const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir) if (!localDir) return { ok: false, reason: '还没选本地目录', results: [], done: 0, failed: 0 } const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? '')) const items = (args.items || []).filter(i => i && i.relPath) const r = await applySyncItems({ peer, localDir, remotePath, token, relationId: rel?.relationId, store: ctx.manifestStore, selfDeviceId: ctx.self.deviceId, items, onProgress: (relPath, sent, total) => { broadcastToRenderer('sync:progress', { peerId: args.peerId, relPath, sent, total }) }, }) recordAudit({ action: 'sync.apply', target: args.peerId, result: r.failed === 0 ? 'ok' : 'error', payload: { count: items.length, done: r.done, failed: r.failed, localDir, remotePath }, }) return r }) // ===== 关系 (同步盘) ===== // // 关系 = 两个设备之间的持久绑定 + 分级授权。 // 建立方式跟远程终端一样: **对方直接弹窗**, 不用邀请码。 ipcMain.handle('relation:list', () => (ctx?.relations.listRelations() || []).map(publicRelation)) // 发起建立关系 -> 对方那边弹窗 ipcMain.handle('relation:request', (_e, args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: Grant[] }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = findPeer(args.peerId) if (!peer) return { ok: false, reason: '找不到该设备' } return ctx.relationService.requestRelation({ peerId: peer.deviceId, peerName: peer.name, peerAddress: peer.address, peerChatPort: peer.chatPort, peerFilePort: peer.filePort, syncLocalDir: args.syncLocalDir || '', syncRemotePath: args.syncRemotePath || '', grants: (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'), }) }) // 改我开放给对方的能力 ipcMain.handle('relation:setGrants', (_e, args: { relationId: string; grants: Grant[] }) => { const grants = (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync') const r = ctx?.relations.setMyGrants(args.relationId, grants) return { ok: !!r, relation: publicRelation(r) } }) // 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关) ipcMain.handle('relation:setSync', (_e, args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) => { const patch: any = {} if (args.syncLocalDir !== undefined) patch.syncLocalDir = args.syncLocalDir if (args.syncRemotePath !== undefined) patch.syncRemotePath = args.syncRemotePath if (args.syncEnabled !== undefined) patch.syncEnabled = !!args.syncEnabled const r = ctx?.relations.setSyncConfig(args.relationId, patch) if (r?.syncEnabled) ctx?.autoSync?.trigger(r.relationId) return { ok: !!r, relation: publicRelation(r) } }) // 吊销关系 (令牌立刻失效) ipcMain.handle('relation:revoke', (_e, args: { relationId: string }) => ctx?.relationService.revokeRelation(args.relationId) || { ok: false, reason: 'no ctx' }) // 彻底删除关系记录 ipcMain.handle('relation:delete', (_e, args: { relationId: string }) => { const ok = ctx?.relations.deleteRelation(args.relationId) || false try { ctx?.manifestStore.deleteManifest(args.relationId) } catch {} return { ok } }) // ---- 应用控制 ---- ipcMain.handle('app:hide', () => { const w = BrowserWindow.getAllWindows()[0] w?.hide() }) ipcMain.handle('app:version', () => app.getVersion()) ipcMain.handle('app:platform', () => process.platform) // ---- 网络接口 (只读, 全网卡通用) ---- ipcMain.handle('network:listInterfaces', (): NetInterface[] => { return listNetInterfaces() }) // ---- 开机自启 ---- ipcMain.handle('autostart:get', () => { if (!app.isPackaged) return false try { return app.getLoginItemSettings().openAtLogin } catch { return false } }) ipcMain.handle('autostart:set', (_e, enabled: boolean) => { if (!app.isPackaged) return false try { app.setLoginItemSettings({ openAtLogin: !!enabled, path: process.execPath, }) return app.getLoginItemSettings().openAtLogin } catch (e: any) { 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, 对方发起的是 serverSession —— 两边都得关得掉 return ctx.forward.closeSession(args.sessionId, 'user') }) 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 grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) => { return replyApproval(args.requestId, args.ok, { remember: args.remember, grants: args.grants, syncLocalDir: args.syncLocalDir, syncRemotePath: args.syncRemotePath, }) }) 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 { return { messageId: row.message_id, type: row.type, fromDeviceId: row.from_id, toDeviceId: row.to_id, ts: row.ts, body: JSON.parse(row.body_json), status: row.status, } as any }