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
+149
View File
@@ -0,0 +1,149 @@
// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口:
// 点开设备 -> 截图 -> 右键图片 -> 再截图。
//
// 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool,
// 所以走 DevTools 协议自己点。
//
// 用法: node scripts/selftest/cdp-shot.cjs <outDir> [port]
const fs = require('node:fs')
const path = require('node:path')
const WebSocket = require('ws')
const outDir = process.argv[2] || '/tmp/lnm-shots'
const port = process.argv[3] || '9222'
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function findPage() {
for (let i = 0; i < 40; i++) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/list`)
const list = await res.json()
const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
if (page) return page
} catch { /* 还没起来 */ }
await sleep(500)
}
throw new Error('连不上 CDP (应用没起来?)')
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 })
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
async function main() {
fs.mkdirSync(outDir, { recursive: true })
const page = await findPage()
const ws = await connect(page.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString())
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
})
const send = (method, params) => new Promise((resolve, reject) => {
const mid = ++id
pending.set(mid, { resolve, reject })
ws.send(JSON.stringify({ id: mid, method, params: params || {} }))
})
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + JSON.stringify(r.exceptionDetails.exception?.description || ''))
return r.result?.value
}
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' })
const p = path.join(outDir, name)
fs.writeFileSync(p, Buffer.from(r.data, 'base64'))
console.log('截图: ' + p)
return p
}
await send('Page.enable')
await send('Runtime.enable')
// 等 Vue 挂载
for (let i = 0; i < 30; i++) {
const ok = await evaluate(`!!document.querySelector('.device-item')`)
if (ok) break
await sleep(500)
}
// 1) 点开设备 (侧边栏第一项)
const clicked = await evaluate(`(() => {
const el = document.querySelector('.device-item')
if (!el) return false
el.click()
return true
})()`)
console.log('点击设备项: ' + clicked)
await sleep(1500)
// 2) 看看聊天区里有没有图片气泡
const info = await evaluate(`(() => {
const imgs = [...document.querySelectorAll('.bubble.image img')]
return {
imageCount: imgs.length,
srcPrefix: imgs[0] ? imgs[0].src.slice(0, 30) : '',
naturalW: imgs[0] ? imgs[0].naturalWidth : 0,
naturalH: imgs[0] ? imgs[0].naturalHeight : 0,
fallbackCount: document.querySelectorAll('.image-fallback').length,
bubbleText: [...document.querySelectorAll('.bubble')].map(b => b.innerText.trim().slice(0, 40)),
}
})()`)
console.log('页面状态: ' + JSON.stringify(info, null, 2))
await shot('01-chat-image.png')
// 3) 右键点图片 -> 看右键菜单
const ctx = await evaluate(`(() => {
const img = document.querySelector('.bubble.image img')
if (!img) return null
const r = img.getBoundingClientRect()
const x = Math.round(r.left + r.width / 2)
const y = Math.round(r.top + r.height / 2)
img.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }))
return { x, y }
})()`)
console.log('右键坐标: ' + JSON.stringify(ctx))
await sleep(800)
const menu = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')].map(e => e.innerText.trim())
return { items, hasCopyImage: items.includes('复制图片') }
})()`)
console.log('右键菜单: ' + JSON.stringify(menu, null, 2))
await shot('02-context-menu.png')
// 4) 点"复制图片" -> 看提示条 (提示只在 imageCopy 返回 ok 时才会出现)
const clickedCopy = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')]
const it = items.find(e => e.innerText.trim() === '复制图片')
if (!it) return false
it.click()
return true
})()`)
console.log('点击"复制图片": ' + clickedCopy)
await sleep(1500)
const toast = await evaluate(`(() => {
const t = document.body.innerText
return { copied: t.includes('图片已复制到剪贴板'), failed: t.includes('复制失败') || t.includes('还没有可复制') }
})()`)
console.log('提示条: ' + JSON.stringify(toast))
await shot('03-after-copy.png')
ws.close()
console.log('done')
}
main().catch(e => { console.error('CDP 失败:', e.message); process.exit(1) })
+95
View File
@@ -12,6 +12,65 @@ function ensure(p) {
return p
}
// ---- nativeImage / clipboard 桩 ----
//
// 真的 Electron 用 Chromium 的图片解码器。桩里只按**魔数**认 PNG/JPEG (别的格式
// 一律给空图, 和真实现一样), 尺寸和编码后体积按**源文件大小**等比给 —— 目的不是
// 还原真图, 而是让自测能确定性地覆盖 "选 PNG 还是 JPEG / 降级重缩 / 复制" 这几条分支。
function emptyImage() {
return {
isEmpty: () => true,
getSize: () => ({ width: 0, height: 0 }),
resize: () => emptyImage(),
toPNG: () => Buffer.alloc(0),
toJPEG: () => Buffer.alloc(0),
toDataURL: () => '',
}
}
function fakeImage(opts) {
const { width, height, pngSize, jpegSize } = opts
return {
isEmpty: () => false,
getSize: () => ({ width, height }),
resize: (o) => fakeImage({
width: o.width || width,
height: Math.round(height * ((o.width || width) / width)),
pngSize,
jpegSize,
}),
toPNG: () => Buffer.alloc(pngSize, 0x89),
toJPEG: (q) => Buffer.alloc(Math.round(jpegSize * ((q == null ? 80 : q) / 80)), 0xff),
toDataURL: () => 'data:image/png;base64,' + Buffer.alloc(32, 1).toString('base64'),
}
}
function sniffImage(p) {
try {
const fd = fs.openSync(p, 'r')
const buf = Buffer.alloc(8)
fs.readSync(fd, buf, 0, 8, 0)
fs.closeSync(fd)
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png'
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg'
return null
} catch {
return null
}
}
function fakeImageForPath(p) {
const size = fs.statSync(p).size
return fakeImage({
width: 1200, height: 900,
// PNG 通常比源文件还大 (照片尤其), JPEG 大约三成 —— 够触发不同分支
pngSize: Math.round(size * 1.5),
jpegSize: Math.round(size * 0.3),
})
}
const clipboardState = { image: null, text: '' }
module.exports = {
app: {
isPackaged: false,
@@ -45,4 +104,40 @@ module.exports = {
dialog: { showErrorBox: () => {}, showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
shell: { openPath: async () => '', showItemInFolder: () => {} },
Notification: class { static isSupported() { return false } constructor() {} show() {} },
nativeImage: {
createFromPath(p) {
try {
if (!fs.existsSync(p)) return emptyImage()
if (!sniffImage(p)) return emptyImage()
return fakeImageForPath(p)
} catch {
return emptyImage()
}
},
createFromDataUrl(u) {
// 只认 data:image/png;base64 和 data:image/jpeg;base64
const m = /^data:image\/(png|jpeg);base64,/.exec(String(u || ''))
if (!m) return emptyImage()
return fakeImage({ width: 1200, height: 900, pngSize: 40_000, jpegSize: 12_000 })
},
createFromBuffer(b) {
if (!b || b.length < 8) return emptyImage()
const kind = b[0] === 0x89 && b[1] === 0x50 ? 'png' : (b[0] === 0xff && b[1] === 0xd8 ? 'jpeg' : null)
if (!kind) return emptyImage()
return fakeImage({
width: 1200, height: 900,
pngSize: Math.round(b.length * 1.5),
jpegSize: Math.round(b.length * 0.3),
})
},
},
clipboard: {
writeImage(img) { clipboardState.image = img },
readImage() { return clipboardState.image },
writeText(t) { clipboardState.text = String(t) },
readText() { return clipboardState.text },
},
// 只给自测用: 看剪贴板里到底写进去了什么
__clipboardState: clipboardState,
}
+138
View File
@@ -0,0 +1,138 @@
// 往 App 的 SQLite 里注入一条"对方发来的图片消息", 用来肉眼验证:
// 1. 图片气泡是不是直接显示图 (而不是 "[图片] xxx" 的文件卡片)
// 2. 右键菜单里有没有"复制图片"
//
// 用法: node scripts/selftest/inject-image-msg.cjs <dbPath>
//
// 注入完让 App 重启一次 —— 设备列表是启动时从 DB 读的。
const path = require('node:path')
const fs = require('node:fs')
const zlib = require('node:zlib')
const Database = require('better-sqlite3')
const dbPath = process.argv[2]
if (!dbPath) {
console.error('用法: node scripts/selftest/inject-image-msg.cjs <dbPath>')
process.exit(1)
}
if (!fs.existsSync(dbPath)) {
console.error(`找不到 DB: ${dbPath}`)
process.exit(1)
}
// ---- 手写一张 PNG (红黄渐变 + 蓝色方块), 当作"对方发来的图" ----
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
}
function makePng(w, h, pixel) {
const raw = Buffer.alloc((w * 3 + 1) * h)
for (let y = 0; y < h; y++) {
const rs = y * (w * 3 + 1)
raw[rs] = 0
for (let x = 0; x < w; x++) {
const [r, g, b] = pixel(x, y)
const o = rs + 1 + x * 3
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b
}
}
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] = 2
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
])
}
const W = 640, H = 420
const png = makePng(W, H, (x, y) => {
// 渐变底 + 中间一个深色方块 + 底下画几道白条 (能看出缩放后的锯齿)
const r = 240 - Math.floor((x / W) * 180)
const g = 120 + Math.floor((y / H) * 120)
const b = 60 + Math.floor((x / W) * 150)
if (x > W * 0.28 && x < W * 0.72 && y > H * 0.22 && y < H * 0.66) return [30, 40, 90]
if (y > H * 0.78 && y < H * 0.84 && x > W * 0.1 && x < W * 0.9) return [255, 255, 255]
if (y > H * 0.88 && y < H * 0.93 && x > W * 0.1 && x < W * 0.7) return [255, 255, 255]
return [r, g, b]
})
const thumbDataUrl = 'data:image/png;base64,' + png.toString('base64')
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
// 本机 deviceId (持久化在 data/device.id) —— conversation_key 要用它算
const idFile = path.join(path.dirname(dbPath), 'device.id')
const selfId = fs.readFileSync(idFile, 'utf8').trim()
const peerId = 'peer-demo1111-0000-4000-8000-0000000000aa'
const now = Date.now()
const convKey = [peerId, selfId].sort().join('::')
db.prepare(`INSERT OR REPLACE INTO devices
(device_id, name, hostname, platform, app_version, last_ip, last_seen, drive_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
peerId, '测试设备B', 'demo-b', 'linux', '0.1.0', '192.168.1.88', now, null,
)
// 把原图也落到磁盘 —— 这样"右键复制"走的是**本地文件**那条路 (复制原图, 不是缩略图)
const imgFile = path.join(path.dirname(dbPath), 'demo-image.png')
fs.writeFileSync(imgFile, png)
const body = {
type: 'image',
messageId: 'msg-demo-image-0001',
ts: now,
fromDeviceId: peerId,
toDeviceId: selfId,
fileId: 'file-demo-image-0001',
name: '演示图片.png',
size: png.length,
mime: 'image/png',
width: W,
height: H,
// 关键: 消息体里带缩略图 -> 气泡直接显示图
thumbDataUrl,
// 本地原图 (接收端落地路径) —— 供"复制图片"和"点开看大图"用
savedPath: imgFile,
}
db.prepare(`INSERT OR REPLACE INTO messages
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
'msg-demo-image-0001', 'image', peerId, selfId, now, JSON.stringify(body), 'delivered', convKey,
)
// 再来一条文字消息, 方便看气泡排版
db.prepare(`INSERT OR REPLACE INTO messages
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
'msg-demo-text-0001', 'text', peerId, selfId, now - 1000,
JSON.stringify({
type: 'text', messageId: 'msg-demo-text-0001', ts: now - 1000,
fromDeviceId: peerId, toDeviceId: selfId, content: '看这张图 👆',
}), 'delivered', convKey,
)
db.close()
console.log(`已注入: 设备 "${peerId}" + 1 条图片消息 (${Math.round(png.length / 1024)}KB) + 1 条文字消息`)
console.log(`self=${selfId} conversation_key=${convKey}`)
console.log(`原图: ${imgFile}`)
console.log(`DB: ${dbPath}`)
+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()
+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)
})