Files
LocalNetMsg/src/main/ipc.ts
T
xiaoshuai 2b63bf7d0b feat(image): 图片直接显示 + 右键复制图片; 修未读角标不清
## 图片直显 + 右键复制

以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。

根因有两层:

1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的,
   对方只能看到文件卡片。
2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`,
   大图没有缩略图 -> 渲染层 fallback 成文件卡片。

改法:

- 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**,
  对端不用再解码, 老版本对端也看得到图。
- 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp:
  宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG,
  然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。
  阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。
  源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。
- 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传),
  只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。
- 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
  (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。
- 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成,
  失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。

## 修未读角标不清

用户正看着某个会话, dock/任务栏角标的数字却不消。

根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读),
而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。
文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。

改法:

- 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()`
  (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。
- 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。
- 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。

## 验证

- npm run typecheck        ✅
- npm run build            ✅
- npm run selftest         ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标)
- npm run selftest:sync    ✅ 全部通过
- npm run selftest:image   ✅ 真 Electron 下验 nativeImage (新增命令)
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4

真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个),
右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
2026-10-21 14:52:37 +08:00

1586 lines
66 KiB
TypeScript

// 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<string, boolean> {
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<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>()
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<string>()
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
}