Files
LocalNetMsg/scripts/selftest/inject-image-msg.cjs
T
xiaoshuai 2b63bf7d0b 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 原图** (不是缩略图)。
2026-10-21 14:52:37 +08:00

139 lines
5.0 KiB
JavaScript

// 往 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}`)