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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user