2b63bf7d0b
## 图片直显 + 右键复制 以前图片消息基本只能看到 "[图片] 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 原图** (不是缩略图)。
139 lines
5.0 KiB
JavaScript
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}`)
|