initial: LAN IM desktop app (Electron 32 + Vue 3 + TS + SQLite)
This commit is contained in:
+805
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user