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 原图** (不是缩略图)。
This commit is contained in:
2026-10-21 14:52:37 +08:00
parent d854c95ee6
commit 2b63bf7d0b
19 changed files with 1344 additions and 45 deletions
+54 -2
View File
@@ -31,6 +31,8 @@ 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'
@@ -609,8 +611,9 @@ export function registerIpc() {
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 }
})
@@ -704,6 +707,10 @@ export function registerIpc() {
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',
@@ -713,6 +720,7 @@ export function registerIpc() {
savedPath: null as string | null,
hash: null as string | null,
localPath: args.localPath, // retry 时复用
...(thumbDataUrl ? { thumbDataUrl } : {}),
...(args.replyTo ? { replyTo: args.replyTo } : {}),
}
const env: MessageEnvelope = {
@@ -815,12 +823,18 @@ export function registerIpc() {
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: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
thumbDataUrl,
dataBase64: args.dataBase64,
...(args.replyTo ? { replyTo: args.replyTo } : {}),
}
@@ -1109,6 +1123,44 @@ export function registerIpc() {
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 里的历史记录