initial: LAN IM desktop app (Electron 32 + Vue 3 + TS + SQLite)

This commit is contained in:
2026-07-15 18:31:13 +08:00
commit 07c7260a3c
43 changed files with 12320 additions and 0 deletions
+805
View File
@@ -0,0 +1,805 @@
// IPC 处理: 渲染进程 -> 主进程 的所有调用
import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron'
import { randomUUID } from 'node:crypto'
import { existsSync, statSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { promises as fsp } from 'node:fs'
import { getSettings, updateSettings, ensureDownloadDir } from './settings'
import {
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
getUnread, totalUnread, listPendingMessages, listAllPending,
setDeviceIgnored, listIgnoredDevices, deleteConversation
} 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 { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage } from './protocol'
interface NetCtx {
self: DeviceInfo
discovery: Discovery
chatServer: ChatServer
chatClient: ChatClient
fileServer: FileServer
}
let ctx: NetCtx | null = null
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "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} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`)
try { deleteMessage(messageId) } catch {}
broadcastToRenderer('message:recalled', { messageId, conversationKey: '' })
// 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark 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)
}
}
export function bindNetworkContext(c: NetCtx) {
ctx = c
// 网络事件 -> 渲染进程
c.discovery.on('found', (d) => {
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
})
broadcastToRenderer('device:found', d)
c.chatClient.connectTo(d)
})
c.discovery.on('updated', (d: DeviceInfo) => {
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
})
broadcastToRenderer('device:updated', d)
c.chatClient.connectTo(d)
})
c.discovery.on('lost', (id) => {
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 })
}
})
c.chatServer.on('connect', (_ws, peer) => {
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
// 对端上线 -> 触发离线消息 flush
flushPendingFor(peer.deviceId)
})
// 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}`)
})
c.chatServer.on('disconnect', (id) => {
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)
})
}
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) {
// 对方不在线, 保持 pending, 等 flush
return { ok: false, reason: 'peer offline', 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 让他出 "打开/位置"
for (const row of rows) {
const body = JSON.parse(row.body_json)
const fileBody = body as any
if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) {
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('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
})
// ---- 消息 ----
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 }) => {
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,
} 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
}) => {
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
// 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 时复用
}
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 (对端气泡更新, 出现"打开")
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
}
// 不用再 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
}) => {
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()
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: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
dataBase64: args.dataBase64,
}
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' })
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
}
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: 'no message' }
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' }
// 删除本地
deleteMessage(messageId)
// 通知对端
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: 'peer offline', status: 'pending' }
}
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
// 通知前端并重启 broadcast 频次
}
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 }
})
// ---- 应用控制 ----
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
}
})
}
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
}