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
+179
View File
@@ -0,0 +1,179 @@
// 真实 Electron 下验证 nativeImage 的实际行为 (一次性校验脚本, 不是自测的一部分)
//
// 为什么要单独验: 自测里 electron 是**桩**, nativeImage 是假的。
// 这里用真的 Electron 跑, 确认:
// 1. nativeImage 能解 JPEG / PNG
// 2. resize 后 toPNG/toJPEG 的体积落在预期范围
// 3. **真照片** PNG 编码远大于 JPEG -> 所以缩略图优先 PNG、超限才退 JPEG
// 4. **透明 PNG 转 JPEG 会丢透明** (alpha 全变 255, 透明区变黑)
// -> 所以透明截图必须走 PNG, 这是"优先 PNG"那条规则的原因
// 5. clipboard.writeImage 真能写进系统剪贴板, 且写的是原图
// 6. 不存在/非图片的文件 -> 空图, 不抛异常
//
// 用法: npm run selftest:image
const { app, nativeImage, clipboard } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const zlib = require('node:zlib')
// 生产代码本体 (esbuild 打的包, --external:electron -> 这里拿到的是**真** electron)
const imageMod = require('./.out/image-native.cjs')
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lnm-native-'))
let failed = false
function check(name, cond, detail) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
if (!cond) failed = true
}
// ---- 手写 PNG 编码器 (够用即可: 8bit RGB / RGBA, filter=none) ----
let crcTable = null
function crc32(buf) {
if (!crcTable) {
crcTable = new Int32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
crcTable[n] = c
}
}
let c = -1
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
return c ^ -1
}
/**
* @param {number} w
* @param {number} h
* @param {(x:number,y:number)=>[number,number,number,number]} pixel -> r,g,b,a
*/
function writePng(file, w, h, pixel) {
// 逐行扫一遍, 看有没有半透明/全透明像素 —— 有就写 RGBA, 否则写 RGB
let hasAlpha = false
for (let y = 0; y < h && !hasAlpha; y++) {
for (let x = 0; x < w; x++) {
if (pixel(x, y)[3] < 255) { hasAlpha = true; break }
}
}
const bpp = hasAlpha ? 4 : 3
const raw = Buffer.alloc((w * bpp + 1) * h)
for (let y = 0; y < h; y++) {
const rowStart = y * (w * bpp + 1)
raw[rowStart] = 0 // filter: none
for (let x = 0; x < w; x++) {
const [r, g, b, a] = pixel(x, y)
const o = rowStart + 1 + x * bpp
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b
if (hasAlpha) raw[o + 3] = a
}
}
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
const td = Buffer.concat([Buffer.from(type, 'ascii'), data])
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0)
return Buffer.concat([len, td, crc])
}
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
ihdr[8] = 8; ihdr[9] = hasAlpha ? 6 : 2
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 0 })),
chunk('IEND', Buffer.alloc(0)),
])
fs.writeFileSync(file, png)
return png
}
app.whenReady().then(() => {
// ---------- 1) 照片样图 (有噪声): PNG 编码远大于 JPEG ----------
const photoPath = path.join(dir, 'photo.png')
let seed = 12345
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
const photoBytes = writePng(photoPath, 1200, 900, () => [
Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256), 255,
])
// 透明图 (下半区全透明) —— 用来验 "PNG 优先, 不转 JPEG"
const alphaPath = path.join(dir, 'alpha.png')
writePng(alphaPath, 400, 300, (x, y) => (y < 150 ? [255, 0, 0, 255] : [0, 255, 0, 0]))
const img = nativeImage.createFromPath(photoPath)
check('nativeImage 能解 PNG', !img.isEmpty())
const size = img.getSize()
check('尺寸读得对', size.width === 1200 && size.height === 900, `${size.width}x${size.height}`)
const small = img.resize({ width: 480, quality: 'good' })
const ss = small.getSize()
check('resize 到 480 宽 (高按比例)', ss.width === 480 && ss.height === 360, `${ss.width}x${ss.height}`)
const pngOut = small.toPNG()
const jpegOut = small.toJPEG(80)
console.log(` 照片缩略图体积: PNG=${pngOut.length}B JPEG=${jpegOut.length}B 源PNG=${photoBytes.length}B`)
check('照片场景 JPEG 明显小于 PNG', jpegOut.length < pngOut.length / 2, `JPEG=${jpegOut.length}B PNG=${pngOut.length}B`)
// ---------- 1b) 走**生产代码**的缩略图生成 (质量阶梯) ----------
// 纯噪声是最坏情况: q80 会超 60KB, 阶梯应该降质量/缩尺寸把它压回来
const thumb = imageMod.makeThumbFromFile(photoPath)
check('生产代码能出缩略图', !!thumb && thumb.startsWith('data:image/'), (thumb || '').slice(0, 30))
if (thumb) {
const bytes = Buffer.from(thumb.split(',')[1], 'base64').length
console.log(` 生产缩略图: ${bytes}B (${thumb.slice(0, 22)})`)
check('生产缩略图压到 60KB 内', bytes <= 60_000, `${bytes}B`)
}
// 透明 PNG: 生产代码必须走 PNG (保住透明), 不能转 JPEG
const alphaThumb = imageMod.makeThumbFromFile(alphaPath)
check('透明图走 PNG (保透明)', !!alphaThumb && alphaThumb.startsWith('data:image/png;base64,'), (alphaThumb || '').slice(0, 24))
// ---------- 2) 透明 PNG: 转 JPEG 会丢透明 (这就是"优先 PNG"的原因) ----------
const alphaImg = nativeImage.createFromPath(alphaPath)
check('透明 PNG 能解', !alphaImg.isEmpty())
// toBitmap 恒为 BGRA (4 字节/像素), 不管原格式
const bmp = alphaImg.toBitmap()
const bpp = 4
const px = (b, x, y) => {
const o = (y * alphaImg.getSize().width + x) * bpp
return { b: b[o], g: b[o + 1], r: b[o + 2], a: b[o + 3] }
}
check('PNG 位图里下半区是全透明 (alpha=0)', px(bmp, 200, 250).a === 0, `alpha=${px(bmp, 200, 250).a}`)
check('PNG 位图里上半区不透明', px(bmp, 200, 50).a === 255, `alpha=${px(bmp, 200, 50).a}`)
const alphaJpeg = alphaImg.toJPEG(80)
const alphaBmp = alphaImg.toBitmap() // 再取一次 (原图)
check('JPEG 编码后长度合理', alphaJpeg.length > 0)
// 把 JPEG 解回来看 alpha 变成什么
const roundTrip = nativeImage.createFromBuffer(alphaJpeg)
const rtBmp = roundTrip.toBitmap()
const rtPx = px(rtBmp, 200, 250)
check('透明区转 JPEG 后 alpha 变成不透明 (透明丢了)', rtPx.a === 255, `alpha=${rtPx.a}`)
void alphaBmp
// ---------- 3) data URL ----------
const durl = small.toDataURL()
check('toDataURL 产出 data:image/png', durl.startsWith('data:image/png;base64,'), durl.slice(0, 30))
// base64 长度 = ceil(len/3)*4, 再加 "data:image/png;base64," 前缀
const expectLen = Math.ceil(pngOut.length / 3) * 4 + 'data:image/png;base64,'.length
check('data URL 长度与 PNG 字节数吻合', durl.length === expectLen, `${durl.length} vs ${expectLen}`)
// ---------- 4) 剪贴板: 写进去再读回来 ----------
clipboard.writeImage(img)
const back = clipboard.readImage()
check('剪贴板写入后可读回', !back.isEmpty())
const bs = back.getSize()
check('剪贴板里是原图尺寸 (不是缩略图)', bs.width === 1200 && bs.height === 900, `${bs.width}x${bs.height}`)
// ---------- 5) 坏输入: 空图而不是抛异常 ----------
const missing = nativeImage.createFromPath(path.join(dir, 'nope.png'))
check('不存在的文件 -> 空图而不是抛异常', missing.isEmpty())
const txt = path.join(dir, 'a.txt')
fs.writeFileSync(txt, 'hello')
check('非图片 -> 空图', nativeImage.createFromPath(txt).isEmpty())
check('空 buffer -> 空图', nativeImage.createFromBuffer(Buffer.alloc(0)).isEmpty())
console.log(failed ? '\n有失败项' : '\n全部通过')
app.exit(failed ? 1 : 0)
})