From 2b63bf7d0b2fcd61f552b2b0cc356c5809ed6316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timeless=E5=B0=8F=E5=B8=85?= <1018008088@qq.com> Date: Wed, 21 Oct 2026 14:52:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(image):=20=E5=9B=BE=E7=89=87=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E6=98=BE=E7=A4=BA=20+=20=E5=8F=B3=E9=94=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E5=9B=BE=E7=89=87;=20=E4=BF=AE=E6=9C=AA=E8=AF=BB?= =?UTF-8?q?=E8=A7=92=E6=A0=87=E4=B8=8D=E6=B8=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 图片直显 + 右键复制 以前图片消息基本只能看到 "[图片] 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 原图** (不是缩略图)。 --- AGENTS.md | 35 ++++ package.json | 2 + scripts/selftest/cdp-shot.cjs | 149 ++++++++++++++ scripts/selftest/electron-stub.cjs | 95 +++++++++ scripts/selftest/inject-image-msg.cjs | 138 +++++++++++++ scripts/selftest/main.ts | 169 ++++++++++++++++ scripts/selftest/native-image-check.cjs | 179 +++++++++++++++++ src/main/image-core.ts | 107 ++++++++++ src/main/image.ts | 194 +++++++++++++++++++ src/main/ipc.ts | 56 +++++- src/preload/index.ts | 7 + src/renderer/src/App.vue | 30 +-- src/renderer/src/api.ts | 7 + src/renderer/src/components/ChatView.vue | 24 ++- src/renderer/src/components/MessageInput.vue | 16 +- src/renderer/src/components/MessageItem.vue | 114 +++++++++-- src/renderer/src/components/Sidebar.vue | 8 + src/renderer/src/utils/format.ts | 27 +++ src/renderer/src/utils/unread.ts | 32 +++ 19 files changed, 1344 insertions(+), 45 deletions(-) create mode 100644 scripts/selftest/cdp-shot.cjs create mode 100644 scripts/selftest/inject-image-msg.cjs create mode 100644 scripts/selftest/native-image-check.cjs create mode 100644 src/main/image-core.ts create mode 100644 src/main/image.ts create mode 100644 src/renderer/src/utils/unread.ts diff --git a/AGENTS.md b/AGENTS.md index 04b90a4..f79f14f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + V | Remote feature self-test (terminal / forward / USB) | `npm run selftest` | | Sync-drive end-to-end test (relation -> sync -> delete propagation) | `npm run selftest:sync` | | Packaged-artifact smoke check (asar unpack + real pty spawn) | `npm run selftest:package` (run after `npm run package:*`) | +| Real-Electron nativeImage check (thumbnail encode / transparency / clipboard) | `npm run selftest:image` (needs a display) | | Windows installer | `npm run package:win` → `dist-Electron\LocalNetMsg-0.1.0-Setup.exe` | | macOS / Linux | `npm run package:mac` / `npm run package:linux` | | Unpacked dir only | `npm run package:dir` | @@ -27,6 +28,14 @@ nodes, real `ChatServer` + `ChatClient` + remote managers. See `scripts/selftest nodes, real WS + HTTP, covering **建立关系 → 双向同步 → 改文件 → 删文件 → 删除传播 → 墓碑防复活**. 改同步引擎后跑它 —— 它守住的是"删了能删掉、删了不会复活"这两条命门。 +`npm run selftest:image` 在**真 Electron**(需要显示器)下验 `nativeImage` 的实际行为: 缩略图编码阶梯、 +PNG 透明通道、转 JPEG 丢透明、剪贴板写读、坏文件降级。它打的是**生产代码** +(`src/main/image.ts`, esbuild 打包且 `--external:electron`), 不是桩 —— 自测里的 electron 是桩, 那套只能验逻辑。 + +视觉验证 (UI 改动后用): `scripts/selftest/inject-image-msg.cjs` 往 App 的 SQLite 注入演示消息, +`scripts/selftest/cdp-shot.cjs` 用 CDP 驱动真实窗口点按钮/右键并截图 (环境里没有 xdotool)。 +用完记得把注入的数据清掉。 + Verify changes via `npm run typecheck` + `npm run selftest` + `npm run selftest:sync` + manual `npm run dev`. ## Layout @@ -41,6 +50,8 @@ src/ db.ts SQLite via better-sqlite3 (data/app.db) settings.ts electron-store wrapper (~/AppData/Roaming/local-net-msg/config.json) notify.ts System Notification + Windows overlay badge + image.ts nativeImage 封装: 缩略图生成 + 复制到剪贴板 + 读原图 + image-core.ts 图片纯逻辑 (阈值/编码选择/选图源) —— 不 import electron, 可单测 ipc.ts All ipcMain.handle() wiring protocol.ts MessageEnvelope, WsFrame, DeviceInfo types relations.ts 关系层: 持久绑定 + per-peer 令牌 + 分级授权 (JSON 存储) @@ -57,6 +68,7 @@ src/ stores/ Pinia: device.ts, message.ts, session.ts api.ts IPC typings (mirrors preload) utils/ format.ts (colorFor, initialsOf, formatSize, ...) + unread.ts (未读处理策略: unreadActionForIncoming) electron.vite.config.ts Three builds → out/{main,preload,renderer}/ ``` @@ -125,6 +137,29 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th 8. **File conflict naming.** Append `_N` before the first `.`: `archive.tar.gz` → `archive_1.tar.gz`, `.bashrc` → `.bashrc_1`. Logic in `file-server.ts` `targetPath()`. +9. **图片消息: 缩略图随消息体走, 右键复制写的是原图.** + 气泡里显示的是 `body.thumbDataUrl` (data URL), **发送方生成一次, 对方直接拿来显示** —— + 所以对端不用再解码一遍, 老版本对端也看得到图。 + 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp: + 宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG, + 然后按"降质量 → 缩尺寸"的阶梯找一档够小的 (`src/main/image-core.ts` 里的纯逻辑, 可单测)。 + 源文件超过 20MB 不生成缩略图 —— nativeImage 解码会把整张图铺成 RGBA 内存。 + **拖拽/选中的图片必须走 `sendFile(asImage: true)`** (有真实路径, 流式上传), + 只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图会撑爆带宽)。 + 右键"复制图片"走 `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图** + (本地文件优先, 退回 `dataBase64`), 不是气泡里那个缩略图。 + 缩略图缺失时 (老消息 / 老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。 + +10. **未读/角标: 正在看的会话必须立刻清未读, 且不能被 `savedPath` 拦下来.** + 踩过的坑: `App.vue` 的 `message:received` 里先判断 `if (!savedPath) return` (等文件落地再算未读), + 结果**文字消息没有 `savedPath`** —— 正好在"清未读"之前就 return 了, 于是用户明明在看这个会话, + 角标数字却一直挂着。文件/图片消息因为第二帧带 `savedPath` 才碰巧能清掉, 所以只有文字消息暴露这个 bug。 + 规矩: **先判 `isActive` -> 清未读 + 回已读回执, 再判 `savedPath`**。 + 决策抽成了纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()` + (`clear` / `wait` / `count`), 自测 U1/U2 守着它 —— 别再把顺序写回 if 里。 + 另外两处也要清: 侧边栏**重复点同一个设备**时 `activePeerId` 不变, watch 不触发, 得显式清一次; + 主进程 `device:delete` 清了 unread 但**忘了 `updateBadge()`**, 角标数字会残留。 + ## Configuration knobs - `LNM_MAX_FILE_SIZE=` env var overrides the 100 GB default in `file-server.ts`. Useful for testing the limit path. diff --git a/package.json b/package.json index c02f1a5..fe105c0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "selftest:sync:build": "esbuild scripts/selftest/sync-e2e.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/sync-e2e.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws", "selftest:sync": "npm run selftest:sync:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/sync-e2e.cjs", "selftest:package": "cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/packaging-check.cjs", + "selftest:image:build": "esbuild src/main/image.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/image-native.cjs --external:electron", + "selftest:image": "npm run selftest:image:build && electron scripts/selftest/native-image-check.cjs", "package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64", "package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac", "package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux", diff --git a/scripts/selftest/cdp-shot.cjs b/scripts/selftest/cdp-shot.cjs new file mode 100644 index 0000000..1163dc1 --- /dev/null +++ b/scripts/selftest/cdp-shot.cjs @@ -0,0 +1,149 @@ +// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口: +// 点开设备 -> 截图 -> 右键图片 -> 再截图。 +// +// 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool, +// 所以走 DevTools 协议自己点。 +// +// 用法: node scripts/selftest/cdp-shot.cjs [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) }) diff --git a/scripts/selftest/electron-stub.cjs b/scripts/selftest/electron-stub.cjs index 2415942..233a0c2 100644 --- a/scripts/selftest/electron-stub.cjs +++ b/scripts/selftest/electron-stub.cjs @@ -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, } + diff --git a/scripts/selftest/inject-image-msg.cjs b/scripts/selftest/inject-image-msg.cjs new file mode 100644 index 0000000..1d91fa6 --- /dev/null +++ b/scripts/selftest/inject-image-msg.cjs @@ -0,0 +1,138 @@ +// 往 App 的 SQLite 里注入一条"对方发来的图片消息", 用来肉眼验证: +// 1. 图片气泡是不是直接显示图 (而不是 "[图片] xxx" 的文件卡片) +// 2. 右键菜单里有没有"复制图片" +// +// 用法: node scripts/selftest/inject-image-msg.cjs +// +// 注入完让 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 ') + 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}`) diff --git a/scripts/selftest/main.ts b/scripts/selftest/main.ts index c28f973..9baa022 100644 --- a/scripts/selftest/main.ts +++ b/scripts/selftest/main.ts @@ -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() diff --git a/scripts/selftest/native-image-check.cjs b/scripts/selftest/native-image-check.cjs new file mode 100644 index 0000000..b16ac5e --- /dev/null +++ b/scripts/selftest/native-image-check.cjs @@ -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) +}) diff --git a/src/main/image-core.ts b/src/main/image-core.ts new file mode 100644 index 0000000..46c74c5 --- /dev/null +++ b/src/main/image-core.ts @@ -0,0 +1,107 @@ +// 图片处理的**纯逻辑** (不 import electron, 所以能在无头自测里直接跑) +// +// 为什么要拆出来: 真正吃 nativeImage 的那层 (image.ts) 在自测环境里只有桩, +// 阈值/编码选择/源挑选这些"容易算错"的部分必须能独立验证。 + +/** 缩略图最大宽度 (像素)。气泡里最多显示 320px 高, 留一倍余量给高分屏 */ +export const THUMB_MAX_WIDTH = 480 + +/** 单张缩略图的字节上限 (base64 之前)。超了就再缩一档 */ +export const THUMB_MAX_BYTES = 60_000 + +/** + * 源文件字节上限。nativeImage 解码会把整张图铺成 RGBA 内存 + * (4000x3000 ≈ 48MB), 太大的图直接放弃缩略图, 走"文件卡片"降级。 + */ +export const THUMB_MAX_SOURCE_BYTES = 20 * 1024 * 1024 + +/** JPEG 编码质量 */ +export const THUMB_JPEG_QUALITY = 80 + +/** 缩略图还是太大时的降级宽度 */ +export const THUMB_FALLBACK_WIDTH = 320 + +const IMAGE_MIME_RE = /^image\/(png|jpe?g|gif|webp|bmp|avif|svg\+xml)$/i + +/** 是不是能当图片处理的 mime */ +export function isImageMime(mime: string | undefined | null): boolean { + if (!mime) return false + return IMAGE_MIME_RE.test(mime.trim()) +} + +/** + * 缩略图目标宽度: 只缩不放 (放大会糊, 而且白白变大) + */ +export function pickThumbWidth(srcWidth: number, maxWidth: number = THUMB_MAX_WIDTH): number { + if (!Number.isFinite(srcWidth) || srcWidth <= 0) return maxWidth + return Math.min(srcWidth, maxWidth) +} + +/** + * 选缩略图编码。 + * + * 优先 PNG —— 它保透明通道 (截图转 JPEG 会变成黑底, 很难看); + * 但 PNG 体积大, 照片类会超限, 那就退 JPEG。 + * + * @returns 'png' | 'jpeg' | null (两种都超限 -> 调用方再缩一档) + */ +export function chooseThumbEncoding( + pngBytes: number, + jpegBytes: number, + limit: number = THUMB_MAX_BYTES, +): 'png' | 'jpeg' | null { + if (pngBytes <= limit) return 'png' + if (jpegBytes <= limit) return 'jpeg' + return null +} + +export type ImageSourceKind = 'file' | 'dataUrl' + +export interface ImageSource { + kind: ImageSourceKind + /** kind='file' 时: 本机磁盘上的图片文件 */ + path?: string + /** kind='dataUrl' 时: 完整的 data URL */ + dataUrl?: string + mime?: string + /** 原始字节的 base64 (自带 body.dataBase64 的粘贴图片才有) */ + base64?: string +} + +/** + * 从消息体里挑"这张图的来源"。 + * + * 优先级: **本地文件 > 内嵌数据**。 + * - 本地文件: 自己发的用 localPath (原图), 对方发的用 savedPath (落地后的原图) + * - 内嵌数据: thumbDataUrl (缩略图) 或 dataBase64 (粘贴的原始字节) + * + * 注意: 发送方 DB 里的 savedPath 其实是**接收方**的磁盘路径 (来自上传响应), + * 对发送方自己没用 —— 所以自己发的消息必须优先用 localPath。 + */ +export function pickImageSource(body: any, isSelf: boolean): ImageSource | null { + const b = body || {} + const mime: string | undefined = typeof b.mime === 'string' ? b.mime : undefined + + const localPath = typeof b.localPath === 'string' && b.localPath ? b.localPath : undefined + const savedPath = typeof b.savedPath === 'string' && b.savedPath ? b.savedPath : undefined + // 自己发的优先 localPath, 对方发的优先 savedPath + const path = (isSelf ? (localPath || savedPath) : (savedPath || localPath)) + + const thumb = typeof b.thumbDataUrl === 'string' && b.thumbDataUrl.startsWith('data:image/') + ? b.thumbDataUrl : undefined + const raw = typeof b.dataBase64 === 'string' && b.dataBase64 ? b.dataBase64 : undefined + + if (path) return { kind: 'file', path, mime, dataUrl: thumb, base64: raw } + if (raw && mime) return { kind: 'dataUrl', dataUrl: `data:${mime};base64,${raw}`, mime } + if (thumb) return { kind: 'dataUrl', dataUrl: thumb, mime } + return null +} + +/** + * 消息体里有没有"能直接显示"的缩略图。 + * 没有的话渲染层会走 IPC 现场生成 (老消息 / 老版本对端都没带缩略图)。 + */ +export function hasInlineThumb(body: any): boolean { + const t = (body || {}).thumbDataUrl + return typeof t === 'string' && t.startsWith('data:image/') && t.length > 32 +} diff --git a/src/main/image.ts b/src/main/image.ts new file mode 100644 index 0000000..53ddd0a --- /dev/null +++ b/src/main/image.ts @@ -0,0 +1,194 @@ +// 图片的"重活"都在这: 解码、缩略图、剪贴板。 +// +// 用 Electron 的 nativeImage (Chromium 的图片解码器) 而不是引入 sharp/jimp —— +// Electron 本来就带着它, 零额外依赖, 而且 JPEG/PNG/WebP/BMP/GIF(首帧)都能解。 +// +// 两个用途: +// 1. makeThumb* —— 生成缩略图 data URL, 塞进消息体, 气泡里直接显示 +// 2. copyToClipboard —— 右键"复制图片", 写的是**原图**不是缩略图 + +import { nativeImage, clipboard } from 'electron' +import { existsSync, statSync } from 'node:fs' +import { + THUMB_MAX_BYTES, THUMB_MAX_SOURCE_BYTES, THUMB_JPEG_QUALITY, THUMB_FALLBACK_WIDTH, + pickThumbWidth, chooseThumbEncoding, type ImageSource, +} from './image-core' + +/** 缩略图缓存: key = 路径+大小+mtime。同一张图只解码一次 */ +const thumbCache = new Map() +const THUMB_CACHE_MAX = 200 + +function cachePut(key: string, val: string) { + if (thumbCache.size >= THUMB_CACHE_MAX) { + // 简单 FIFO 淘汰 (Map 保持插入顺序) + const first = thumbCache.keys().next() + if (!first.done) thumbCache.delete(first.value) + } + thumbCache.set(key, val) +} + +/** 缩略图 data URL; 解不了/太大就返回 undefined (调用方降级成文件卡片) */ +export function makeThumbFromFile(filePath: string): string | undefined { + if (!filePath) return undefined + try { + if (!existsSync(filePath)) return undefined + const st = statSync(filePath) + if (!st.isFile()) return undefined + if (st.size > THUMB_MAX_SOURCE_BYTES) return undefined + + const key = `${filePath}|${st.size}|${st.mtimeMs}` + const hit = thumbCache.get(key) + if (hit) return hit + + const img = nativeImage.createFromPath(filePath) + if (!img || img.isEmpty()) return undefined + const out = encodeThumb(img) + if (out) cachePut(key, out) + return out + } catch { + return undefined + } +} + +/** 从 data URL 生成缩略图 (粘贴图片 / 已有缩略图再缩) */ +export function makeThumbFromDataUrl(dataUrl: string): string | undefined { + if (!dataUrl || !dataUrl.startsWith('data:image/')) return undefined + try { + const img = nativeImage.createFromDataURL(dataUrl) + if (!img || img.isEmpty()) return undefined + return encodeThumb(img) + } catch { + return undefined + } +} + +/** 从原始字节生成缩略图 (剪贴板粘贴的图片) */ +export function makeThumbFromBuffer(buf: Buffer): string | undefined { + if (!buf || buf.length === 0) return undefined + if (buf.length > THUMB_MAX_SOURCE_BYTES) return undefined + try { + const img = nativeImage.createFromBuffer(buf) + if (!img || img.isEmpty()) return undefined + return encodeThumb(img) + } catch { + return undefined + } +} + +/** 统一入口: 拿一个来源, 尽量给它出个缩略图 */ +export function makeThumb(src: ImageSource | null | undefined): string | undefined { + if (!src) return undefined + if (src.kind === 'file' && src.path) { + const t = makeThumbFromFile(src.path) + if (t) return t + // 本地文件解不了 -> 退回内嵌数据 + if (src.base64 && src.mime) return makeThumbFromBuffer(Buffer.from(src.base64, 'base64')) + if (src.dataUrl) return makeThumbFromDataUrl(src.dataUrl) + return undefined + } + if (src.dataUrl) { + // 已经是 data URL: 如果本身就是小图, 直接用; 否则再缩 + if (src.dataUrl.length <= THUMB_MAX_BYTES * 2) return src.dataUrl + return makeThumbFromDataUrl(src.dataUrl) + } + if (src.base64) return makeThumbFromBuffer(Buffer.from(src.base64, 'base64')) + return undefined +} + +/** + * 缩略图编码: 缩到目标宽度 -> 按"质量阶梯"找一个够小的编码. + * + * 阶梯顺序是有讲究的: + * 1) PNG 优先 —— 保透明通道 (截图转 JPEG 会变黑底, 很难看); 够小就直接用 + * 2) JPEG 降质量 (80 -> 60) —— 比缩尺寸更能保住细节 + * 3) 再缩尺寸 (320 -> 240) + * 4) 兜底给最小的一档 (宁可超限也别没有图) + */ +function encodeThumb(img: Electron.NativeImage): string | undefined { + const { width, height } = img.getSize() + if (!width || !height) return undefined + const target = pickThumbWidth(width) + const resized = target < width ? img.resize({ width: target, quality: 'good' }) : img + + // 1) 先按"PNG 优先, 超限退 JPEG"选一次 + const png = resized.toPNG() + const jpeg = resized.toJPEG(THUMB_JPEG_QUALITY) + const enc = chooseThumbEncoding(png.length, jpeg.length) + if (enc === 'png') return `data:image/png;base64,${png.toString('base64')}` + if (enc === 'jpeg') return `data:image/jpeg;base64,${jpeg.toString('base64')}` + + // 2) JPEG 降质量 (比缩尺寸更能保住细节) + for (const q of [60]) { + const j2 = resized.toJPEG(q) + if (j2.length <= THUMB_MAX_BYTES) return `data:image/jpeg;base64,${j2.toString('base64')}` + } + + // 3) 缩尺寸再试 + for (const w of [THUMB_FALLBACK_WIDTH, 240]) { + const small = img.resize({ width: w, quality: 'good' }) + const j3 = small.toJPEG(70) + if (j3.length <= THUMB_MAX_BYTES) return `data:image/jpeg;base64,${j3.toString('base64')}` + const p2 = small.toPNG() + if (p2.length <= THUMB_MAX_BYTES) return `data:image/png;base64,${p2.toString('base64')}` + } + + // 4) 兜底: 最小的一档, 宁可略超限也别让气泡退回文件卡片 + const tiny = img.resize({ width: THUMB_FALLBACK_WIDTH, quality: 'good' }).toJPEG(60) + return `data:image/jpeg;base64,${tiny.toString('base64')}` +} + +export interface CopyResult { ok: boolean; reason?: string } + +/** + * 复制图片到系统剪贴板。 + * + * 写的是**原图** (本地文件 / 原始字节), 不是气泡里那个缩略图 —— 粘到 + * 微信/文档里要能直接用。原生图解不了时退回缩略图。 + */ +export function copyToClipboard(src: ImageSource | null | undefined): CopyResult { + if (!src) return { ok: false, reason: '没有可复制的图片' } + try { + let img: Electron.NativeImage | null = null + + if (src.kind === 'file' && src.path) { + img = nativeImage.createFromPath(src.path) + } + if ((!img || img.isEmpty()) && src.base64) { + img = nativeImage.createFromBuffer(Buffer.from(src.base64, 'base64')) + } + if ((!img || img.isEmpty()) && src.dataUrl) { + img = nativeImage.createFromDataURL(src.dataUrl) + } + if (!img || img.isEmpty()) { + return { ok: false, reason: '图片无法解码 (格式不支持, 或文件已被删除)' } + } + clipboard.writeImage(img) + return { ok: true } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } +} + +/** + * 读本地图片成 data URL —— 给"点开看大图"用 (气泡里是缩略图, 放大要原图)。 + * 超大文件直接拒绝, 免得把几百 MB 塞进 IPC。 + */ +export function readImageDataUrl(filePath: string, maxBytes = THUMB_MAX_SOURCE_BYTES): { ok: boolean; dataUrl?: string; reason?: string } { + if (!filePath) return { ok: false, reason: '没有本地文件' } + try { + if (!existsSync(filePath)) return { ok: false, reason: '文件不存在' } + const st = statSync(filePath) + if (!st.isFile()) return { ok: false, reason: '不是文件' } + if (st.size > maxBytes) return { ok: false, reason: '图片太大, 无法预览原图' } + const img = nativeImage.createFromPath(filePath) + if (!img || img.isEmpty()) return { ok: false, reason: '图片无法解码' } + return { ok: true, dataUrl: img.toDataURL() } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } +} + +/** 只给自测用: 清缓存 */ +export function __clearThumbCache() { + thumbCache.clear() +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2de57c7..cb692fa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -31,6 +31,8 @@ import { ForwardManager } from './remote/forward' import { UsbBridgeManager } from './remote/usb' import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval' import { computeWsState } from './ws-state' +import { makeThumb, makeThumbFromFile, makeThumbFromBuffer, copyToClipboard, readImageDataUrl } from './image' +import type { ImageSource } from './image-core' import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol' import { DEFAULT_PORTS } from './protocol' @@ -609,8 +611,9 @@ export function registerIpc() { ctx.chatClient.remove(deviceId) // 停掉 backoff 重连 ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除 broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉 - // 清掉对应未读 + // 清掉对应未读 (顺手刷一次任务栏角标 —— 少了这步, 删了设备角标数字还挂着) clearUnread(deviceId) + updateBadge() return { ok: true } }) @@ -704,6 +707,10 @@ export function registerIpc() { const messageId = randomUUID() const peerInfo: any = peer + // 图片: 现场生成缩略图塞进消息体 —— 发送方生成一次, 对方直接拿来显示, + // 不用在对端再解码一遍 (也顺带让"老版本对端"能显示)。 + const thumbDataUrl = args.asImage ? makeThumbFromFile(args.localPath) : undefined + // 1. 构造初始 envelope (savedPath=null, hash=null) - 气泡先占位, 进度条 0% const baseBody = { type: args.asImage ? 'image' : 'file', @@ -713,6 +720,7 @@ export function registerIpc() { savedPath: null as string | null, hash: null as string | null, localPath: args.localPath, // retry 时复用 + ...(thumbDataUrl ? { thumbDataUrl } : {}), ...(args.replyTo ? { replyTo: args.replyTo } : {}), } const env: MessageEnvelope = { @@ -815,12 +823,18 @@ export function registerIpc() { const fileId = randomUUID() const messageId = randomUUID() + // 缩略图: 小图直接用原字节 (省一次重编码), 大图现生成缩略图 —— + // 以前大图不带缩略图, 气泡只能显示成"[图片] xxx.png"的文件卡片。 + const thumbDataUrl = buf.length < 200_000 + ? `data:${args.mime};base64,${args.dataBase64}` + : makeThumbFromBuffer(buf) + const baseBody: ImageMessage = { type: 'image', messageId, ts: Date.now(), fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, fileId, name: args.name, size: buf.length, mime: args.mime, - thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined, + thumbDataUrl, dataBase64: args.dataBase64, ...(args.replyTo ? { replyTo: args.replyTo } : {}), } @@ -1109,6 +1123,44 @@ export function registerIpc() { return { path: p, name: basename(p), size: st.size } }) + // ===== 图片: 缩略图 / 复制 / 看原图 ===== + // + // 消息体里的 thumbDataUrl 是"发送方生成、双方共用"的缩略图。 + // 但老消息 (落库时还没有缩略图) 和老版本对端发来的消息都没有它 —— + // 这时渲染层会退到 image:thumb 现场生成。 + + // 现场生成缩略图 (渲染层懒加载兜底) + ipcMain.handle('image:thumb', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => { + try { + const src: ImageSource = args.path + ? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 } + : { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 } + const dataUrl = makeThumb(src) + if (!dataUrl) return { ok: false, reason: '无法生成缩略图' } + return { ok: true, dataUrl } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } + }) + + // 右键"复制图片" —— 写的是原图, 不是缩略图 + ipcMain.handle('image:copy', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => { + try { + const src: ImageSource = args.path + ? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 } + : { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 } + return copyToClipboard(src) + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } + }) + + // 点开看大图 —— 读本地原图成 data URL (气泡里是缩略图) + ipcMain.handle('image:dataUrl', async (_e, args: { path?: string }) => { + if (!args?.path) return { ok: false, reason: '没有本地文件' } + return readImageDataUrl(args.path) + }) + // ===== 网盘 (浏览对方共享目录 + 选文件下载) ===== // 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录 diff --git a/src/preload/index.ts b/src/preload/index.ts index 4ba7f11..baf139a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,13 @@ contextBridge.exposeInMainWorld('api', { readBase64: (p: string) => ipcRenderer.invoke('file:readBase64', p), pickFile: (opts?: { image?: boolean }) => ipcRenderer.invoke('file:pick', opts), + // 图片: 缩略图 / 复制到剪贴板 / 看原图 + imageThumb: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => + ipcRenderer.invoke('image:thumb', args), + imageCopy: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => + ipcRenderer.invoke('image:copy', args), + imageDataUrl: (path: string) => ipcRenderer.invoke('image:dataUrl', { path }), + // 拖拽文件 -> 真实磁盘路径 (Electron 32+ 必须用 webUtils.getPathForFile) getPathForFile: (file: File): string => { try { return webUtils.getPathForFile(file) || '' } catch { return '' } diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 0b72cd9..6c4bf77 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -10,6 +10,7 @@ import SettingsView from '@/components/SettingsView.vue' import ContextMenu from '@/components/ContextMenu.vue' import ApprovalDialog from '@/components/remote/ApprovalDialog.vue' import { formatTime } from '@/utils/format' +import { unreadActionForIncoming } from '@/utils/unread' import type { DeviceView, MessageView, Settings } from '@/api' const device = useDeviceStore() @@ -101,14 +102,18 @@ onMounted(async () => { } message.upsert(env) - // 仅在 savedPath 已 ready 时算接收完成, 否则不提示/加未读 (等真正拿到文件再算) - if (!(env.body as any)?.savedPath) { - // 文件还在路上: 仅显示气泡占位, 等下一帧再算 unread - return - } + // 用户正看着这个会话 -> 立刻清未读 + 回已读回执。 + // + // 这段必须在"文件还没落地就 return"**之前**: + // 文字消息没有 savedPath, 以前在这里就 return 了, 导致正在看的会话 + // 永远清不掉未读 -> dock/任务栏角标的数字一直挂着 (文件/图片消息因为 + // 第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息会暴露这个 bug)。 + const action = unreadActionForIncoming({ + isActive: fromId === session.activePeerId, + hasSavedPath: Boolean(savedPath), + }) - const isActive = fromId === session.activePeerId - if (isActive) { + if (action === 'clear') { if (device.unread[fromId]) { const next = { ...device.unread } delete next[fromId] @@ -121,6 +126,10 @@ onMounted(async () => { } return } + + // 不在当前会话: 文件还没落地就先只显示气泡占位, 等第二帧再算未读 + if (action === 'wait') return + device.refresh().then(() => { const sender = device.devices.find(d => d.deviceId === fromId) const name = sender?.name || '新消息' @@ -218,12 +227,7 @@ watch(() => session.activePeerId, async (id) => { } }) -document.addEventListener('click', (e) => { - const t = e.target as HTMLElement - if (t && t.tagName === 'IMG' && (t as any).dataset?.viewer) { - imageViewer.value = (t as HTMLImageElement).src - } -}) +// 图片放大: 由 MessageItem 主动 emit (它要先去主进程取原图, 不能靠全局 click 猜)