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