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
+169
View File
@@ -34,6 +34,14 @@ import { ForwardManager } from '../../src/main/remote/forward'
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
import { updateSettings } from '../../src/main/settings'
import { listAudit } from '../../src/main/db'
import {
isImageMime, pickThumbWidth, chooseThumbEncoding, pickImageSource,
THUMB_MAX_WIDTH,
} from '../../src/main/image-core'
import { makeThumbFromFile, copyToClipboard, __clearThumbCache } from '../../src/main/image'
import { incrUnread, clearUnread, getUnread, totalUnread } from '../../src/main/db'
import { unreadActionForIncoming } from '../../src/renderer/src/utils/unread'
import electronStub from './electron-stub.cjs'
import type {
DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame,
RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame,
@@ -1515,6 +1523,167 @@ async function main() {
})
}
// ============ 图片 (直显 + 右键复制) ============
if (want('image')) {
console.log('\n\x1b[1m[图片]\x1b[0m')
const imgDir = join(tmpdir(), 'lnm-selftest-img')
rmSync(imgDir, { recursive: true, force: true })
mkdirSync(imgDir, { recursive: true })
// 造几个"图片"文件 (只写真头, 桩按魔数识别)
const writeFakeImage = (name: string, bytes: number, kind: 'png' | 'jpeg' = 'png') => {
const p = join(imgDir, name)
const head = kind === 'png'
? Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
: Buffer.from([0xff, 0xd8, 0xff, 0xe0])
writeFileSync(p, Buffer.concat([head, Buffer.alloc(Math.max(0, bytes - head.length), 0x41)]))
return p
}
const smallPng = writeFakeImage('small.png', 10_000)
const midPng = writeFakeImage('mid.png', 60_000)
const bigPng = writeFakeImage('big.png', 400_000)
const jpegFile = writeFakeImage('photo.jpg', 60_000, 'jpeg')
const notImage = join(imgDir, 'note.txt')
writeFileSync(notImage, 'just text')
await runCase('I1 纯逻辑: mime 识别 / 缩略图宽度只缩不放 / PNG 优先否则 JPEG', async () => {
assert(isImageMime('image/png'), 'image/png 应该认')
assert(isImageMime('IMAGE/JPEG'), '大小写不敏感')
assert(!isImageMime('application/octet-stream'), 'octet-stream 不该认')
assert(!isImageMime(''), '空 mime 不该认')
assert(pickThumbWidth(1200) === THUMB_MAX_WIDTH, `1200 应该缩到 ${THUMB_MAX_WIDTH}`)
assert(pickThumbWidth(200) === 200, '200 比上限小就不该放大')
assert(pickThumbWidth(0) === THUMB_MAX_WIDTH, '异常宽度退回上限')
assert(chooseThumbEncoding(1000, 5000) === 'png', 'PNG 够小就该用 PNG (保透明)')
assert(chooseThumbEncoding(100_000, 5000) === 'jpeg', 'PNG 超限就退 JPEG')
assert(chooseThumbEncoding(100_000, 100_000) === null, '两种都超限 -> null (调用方再缩)')
})
await runCase('I2 选图源: 自己发的用 localPath, 对方发的用 savedPath (发送方 DB 里的 savedPath 是对方的路径, 不能用)', async () => {
const body = { localPath: '/me/a.png', savedPath: '/peer/b.png', mime: 'image/png' }
const mine = pickImageSource(body, true)!
assert(mine.kind === 'file' && mine.path === '/me/a.png', `自己发的应选 localPath, 实际 ${mine.path}`)
const theirs = pickImageSource(body, false)!
assert(theirs.kind === 'file' && theirs.path === '/peer/b.png', `对方发的应选 savedPath, 实际 ${theirs.path}`)
// 没有本地文件时, 退回内嵌的 dataBase64 (粘贴的图)
const pasted = pickImageSource({ dataBase64: 'AAAA', mime: 'image/png' }, true)!
assert(pasted.kind === 'dataUrl' && pasted.dataUrl!.startsWith('data:image/png;base64,'), '应退回 data URL')
// 什么都没有 -> null
assert(pickImageSource({ name: 'x.png' }, true) === null, '没源应该返回 null')
})
await runCase('I3 缩略图: 小图走 PNG (保透明), 中图走 JPEG, 超大图降级重缩', async () => {
const small = makeThumbFromFile(smallPng)!
assert(small.startsWith('data:image/png;base64,'), `小图应该是 PNG, 实际 ${small.slice(0, 24)}`)
const mid = makeThumbFromFile(midPng)!
assert(mid.startsWith('data:image/jpeg;base64,'), `中图应该退 JPEG, 实际 ${mid.slice(0, 24)}`)
// 超大图: PNG/JPEG 都超限 -> 降级分支也要给得出结果
const big = makeThumbFromFile(bigPng)!
assert(big.startsWith('data:image/'), '超大图也该给出缩略图 (降级重缩)')
// JPEG 源文件
const jpg = makeThumbFromFile(jpegFile)!
assert(jpg.startsWith('data:image/'), 'JPEG 源也要能出缩略图')
})
await runCase('I4 缩略图: 非图片 / 不存在的文件 -> undefined (优雅降级成文件卡片, 不报错)', async () => {
assert(makeThumbFromFile(notImage) === undefined, 'txt 不该出缩略图')
assert(makeThumbFromFile(join(imgDir, 'nope.png')) === undefined, '不存在的文件不该出缩略图')
assert(makeThumbFromFile('') === undefined, '空路径不该出缩略图')
// 目录也不行
assert(makeThumbFromFile(imgDir) === undefined, '目录不该出缩略图')
})
await runCase('I5 缩略图缓存: 同一张图生成两次 -> 结果一致 (不重复解码)', async () => {
__clearThumbCache()
const a = makeThumbFromFile(midPng)
const b = makeThumbFromFile(midPng)
assert(a === b && !!a, '同一文件应命中缓存')
})
await runCase('I6 右键复制图片: 写的是原图 (本地文件), 不是缩略图', async () => {
const r = copyToClipboard({ kind: 'file', path: smallPng, mime: 'image/png' })
assert(r.ok, `复制应成功: ${r.reason || ''}`)
const written = electronStub.__clipboardState.image
assert(!!written, '剪贴板里应有图片')
const size = written.getSize()
assert(size.width === 1200 && size.height === 900, `应写原图尺寸, 实际 ${size.width}x${size.height}`)
})
await runCase('I7 右键复制图片: 文件没了/解不了 -> 明确失败, 不静默', async () => {
const r = copyToClipboard({ kind: 'file', path: join(imgDir, 'gone.png'), mime: 'image/png' })
assert(!r.ok, '文件不存在应失败')
assert(!!r.reason, '失败要带原因')
const r2 = copyToClipboard(null)
assert(!r2.ok && !!r2.reason, '没有源应失败并带原因')
})
await runCase('I8 缩略图缓存按 mtime 失效: 改了文件 -> 缩略图跟着变', async () => {
__clearThumbCache()
const p = writeFakeImage('mutable.png', 10_000)
const first = makeThumbFromFile(p)!
writeFileSync(p, Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(30_000, 0x42)]))
const second = makeThumbFromFile(p)!
assert(first !== second, '文件改了, 缩略图不该还用缓存')
})
}
// ============ 未读/角标 ============
if (want('unread')) {
console.log('\n\x1b[1m[未读/角标]\x1b[0m')
await runCase('U1 正在看的会话: 不管有没有 savedPath 都要清未读 (文字消息没 savedPath, 以前就是被提前 return 掉的)', async () => {
// 文字消息: 没有 savedPath —— 这是以前漏清未读的那条路径
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: false }) === 'clear',
'正在看 + 文字消息(无 savedPath) 也必须清未读')
// 文件消息第一帧 (文件在路上)
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: false }) === 'clear',
'正在看时第一帧也要清')
// 文件消息第二帧 (落地了)
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: true }) === 'clear',
'正在看 + 文件落地 -> 清')
})
await runCase('U2 不在当前会话: 文件没落地先等, 落地了才算未读', async () => {
assert(unreadActionForIncoming({ isActive: false, hasSavedPath: false }) === 'wait',
'不在当前会话 + 文件在路上 -> 等')
assert(unreadActionForIncoming({ isActive: false, hasSavedPath: true }) === 'count',
'不在当前会话 + 文件落地 -> 算未读')
})
await runCase('U3 未读计数: 加了能清掉, 清完总数归零', async () => {
const peer = 'peer-unread-0000-4000-8000-0000000000cc'
clearUnread(peer)
const base = totalUnread() // 别假设起点是 0 (别的用例可能留了未读)
incrUnread(peer)
assert(totalUnread() === base + 1, `加了未读后总数应 +1, 实际 ${totalUnread() - base}`)
assert((getUnread()[peer] ?? 0) === 1, `该设备的未读数应为 1, 实际 ${getUnread()[peer]}`)
clearUnread(peer)
assert(totalUnread() === base, `清掉后总数应回到 ${base}, 实际 ${totalUnread()}`)
assert((getUnread()[peer] ?? 0) === 0, `该设备的未读数应为 0, 实际 ${getUnread()[peer]}`)
clearUnread(peer)
assert(totalUnread() === base, '重复清也不该出问题')
})
await runCase('U4 未读计数: 多个设备累加, 清一个不影响另一个', async () => {
const p1 = 'peer-unread-0000-4000-8000-0000000000d1'
const p2 = 'peer-unread-0000-4000-8000-0000000000d2'
clearUnread(p1); clearUnread(p2)
const base = totalUnread()
incrUnread(p1); incrUnread(p1); incrUnread(p2)
assert(totalUnread() === base + 3, `三个未读, 实际多了 ${totalUnread() - base}`)
clearUnread(p1)
assert(totalUnread() === base + 1, `清掉一个后应剩 1, 实际多了 ${totalUnread() - base}`)
assert((getUnread()[p2] ?? 0) === 1, `另一个设备的未读不该被动到, 实际 ${getUnread()[p2]}`)
clearUnread(p2)
assert(totalUnread() === base, '全清完应回到基线')
})
}
// ============ 汇总 ============
await A.stop()
await B.stop()