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
+35
View File
@@ -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=<bytes>` env var overrides the 100 GB default in `file-server.ts`. Useful for testing the limit path.
+2
View File
@@ -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",
+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)
})
+107
View File
@@ -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
}
+194
View File
@@ -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<string, string>()
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()
}
+54 -2
View File
@@ -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 里的历史记录
+7
View File
@@ -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 '' }
+17 -13
View File
@@ -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 猜)
</script>
<template>
+7
View File
@@ -35,6 +35,13 @@ export type LnmApiBase = {
pickFile: (opts?: { image?: boolean }) => Promise<{ path: string; name: string; size: number } | null>
getPathForFile: (file: File) => string
// 图片: 缩略图 (老消息/老版本对端没带缩略图时现场生成) / 复制原图 / 读原图
imageThumb: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
Promise<{ ok: boolean; dataUrl?: string; reason?: string }>
imageCopy: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
Promise<{ ok: boolean; reason?: string }>
imageDataUrl: (path: string) => Promise<{ ok: boolean; dataUrl?: string; reason?: string }>
hide: () => Promise<void>
appVersion: () => Promise<string>
+9 -1
View File
@@ -101,8 +101,16 @@ async function onGlobalDrop(e: DragEvent) {
if (!files || files.length === 0) return
let imgN = 0, fileN = 0
for (const file of Array.from(files)) {
const fpath = window.api.getPathForFile(file)
if (file.type.startsWith('image/')) {
imgN++
// 图片走文件通道 (asImage=true): 流式上传 + 自动生成缩略图, 对方能直接看到图
if (fpath) {
window.api.sendFile(props.peer!.deviceId, fpath, {
asImage: true, withProgress: false, mime: file.type || undefined,
}).then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
} else {
// 拿不到路径 -> 退回 buffer 通道
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result as string
@@ -111,9 +119,9 @@ async function onGlobalDrop(e: DragEvent) {
.then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
}
reader.readAsDataURL(file)
}
} else {
fileN++
const fpath = window.api.getPathForFile(file)
if (fpath) {
window.api.sendFile(props.peer!.deviceId, fpath, { asImage: false, withProgress: false })
.then(r => { if (!r.ok) ElMessage.warning('发送失败: ' + (r.reason || '')) })
+13 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import type { DeviceView, MessageView, ReplyRef } from '@/api'
import { useDeviceStore } from '@/stores/device'
import { useSessionStore } from '@/stores/session'
import { isImageFileName, imageMimeOf } from '@/utils/format'
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
import { Link, Picture, Promotion, ChatLineRound, UploadFilled, Close } from '@element-plus/icons-vue'
@@ -104,7 +105,9 @@ async function send() {
async function pickFile() {
const f = await window.api.pickFile()
if (!f) return
await sendLocalFile(f.path, f.name, false)
// 图片要按"图片消息"发 (asImage=true), 否则对方只能看到一个文件卡片
const asImage = isImageFileName(f.name)
await sendLocalFile(f.path, f.name, asImage)
}
async function sendLocalFile(localPath: string, name: string, asImage: boolean) {
@@ -115,6 +118,7 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
const r = await window.api.sendFile(props.peer.deviceId, localPath, {
asImage, withProgress: false,
mime: asImage ? (imageMimeOf(name) || undefined) : undefined,
...(replyRef ? { replyTo: replyRef } : {}),
})
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
@@ -186,7 +190,15 @@ async function onDrop(e: DragEvent) {
for (const file of Array.from(files)) {
if (file.type.startsWith('image/')) {
imageCount++
// 有真实路径的图片直接走文件通道 (流式上传 + 缩略图),
// 别再把整张图读成 base64 塞进 WS 帧 —— 大图会撑爆内存和带宽。
const fpath = window.api.getPathForFile(file)
if (fpath) {
sendLocalFile(fpath, file.name, true)
} else {
// 拿不到路径 (比如从别处粘贴的虚拟文件) -> 退回 buffer 通道
await readImageAsPending(file)
}
} else {
fileCount++
const fpath = window.api.getPathForFile(file)
+84 -10
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import type { MessageView, DeviceView, DeviceSelf, ReplyRef } from '@/api'
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
import MarkdownView from './MarkdownView.vue'
import { ElAvatar, ElButton, ElMessage, ElProgress, ElMessageBox } from 'element-plus'
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft } from '@element-plus/icons-vue'
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft, Picture } from '@element-plus/icons-vue'
import { useMessageStore } from '@/stores/message'
import { useSessionStore } from '@/stores/session'
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
@@ -82,9 +82,10 @@ const isConsecutive = computed(() => {
})
const text = computed(() => (props.msg.body as any).content || '')
const imageUrl = computed(() => {
if (props.msg.type !== 'image') return ''
return (props.msg.body as any).thumbDataUrl || ''
// 消息体自带的缩略图 (发送方生成, 双方共用) —— 有就直接显示
const inlineThumb = computed(() => {
const t = (props.msg.body as any)?.thumbDataUrl
return typeof t === 'string' && t.startsWith('data:image/') ? t : ''
})
const statusText = computed(() => {
@@ -114,6 +115,74 @@ const localFilePath = computed(() => {
const hasLocalFile = computed(() => Boolean(localFilePath.value))
// ---- 图片显示 ----
// 1) 消息体自带缩略图 -> 直接显示 (发送方生成一次, 双方共用)
// 2) 没有 (老消息 / 老版本对端发来的) -> 用本地文件现场生成, 只做一次
const thumbUrl = ref('')
const thumbState = ref<'idle' | 'loading' | 'ready' | 'failed'>('idle')
async function ensureThumb() {
if (props.msg.type !== 'image') return
if (inlineThumb.value) {
thumbUrl.value = inlineThumb.value
thumbState.value = 'ready'
return
}
if (thumbState.value === 'loading' || thumbState.value === 'ready' || thumbState.value === 'failed') return
const p = localFilePath.value
if (!p) {
thumbState.value = 'failed'
return
}
thumbState.value = 'loading'
try {
const r = await api.imageThumb({ path: p, mime: (props.msg.body as any)?.mime })
if (r?.ok && r.dataUrl) {
thumbUrl.value = r.dataUrl
thumbState.value = 'ready'
} else {
thumbState.value = 'failed'
}
} catch {
thumbState.value = 'failed'
}
}
onMounted(ensureThumb)
// 接收方的 savedPath 是第二帧才到 (文件落地后), 到了再补一次缩略图
watch(() => (props.msg.body as any)?.savedPath, ensureThumb)
watch(() => (props.msg.body as any)?.thumbDataUrl, ensureThumb)
// 点开看大图: 气泡里是缩略图, 放大要看原图 (本地有文件才拿得到)
async function openViewer() {
// 先用缩略图立刻打开 (别让用户干等 IPC), 原图到了再替换上去
if (thumbUrl.value) emit('open-image', thumbUrl.value)
const p = localFilePath.value
if (!p) return
try {
const r = await api.imageDataUrl(p)
if (r?.ok && r.dataUrl) emit('open-image', r.dataUrl)
} catch { /* 保持缩略图 */ }
}
// 右键"复制图片" —— 复制的是**原图** (本地文件/原始字节), 不是气泡里的缩略图
async function copyImage() {
const b = props.msg.body as any
const args = {
path: localFilePath.value || undefined,
base64: typeof b?.dataBase64 === 'string' && b.dataBase64 ? b.dataBase64 : undefined,
mime: typeof b?.mime === 'string' ? b.mime : undefined,
dataUrl: thumbUrl.value || undefined,
}
if (!args.path && !args.base64 && !args.dataUrl) {
ElMessage.warning('这张图还没有可复制的内容')
return
}
const r = await api.imageCopy(args)
if (r?.ok) ElMessage.success('图片已复制到剪贴板')
else ElMessage.warning(r?.reason || '复制失败')
}
async function openLocal() {
if (!localFilePath.value) return
const ok = await api.open(localFilePath.value)
@@ -200,6 +269,9 @@ function onMsgCtxMenu(e: MouseEvent) {
if (m.type === 'text') {
items.push({ cmd: 'copy', label: '复制', icon: DocumentCopy })
}
if (m.type === 'image') {
items.push({ cmd: 'copyImage', label: '复制图片', icon: Picture })
}
if (isSelf.value && m.type !== 'system') {
items.push({ cmd: 'recall', label: '撤回', icon: ArrowLeft })
}
@@ -209,6 +281,7 @@ function onMsgCtxMenu(e: MouseEvent) {
}
showContextMenu(e, items, m, (cmd) => {
if (cmd === 'copy') copyText()
else if (cmd === 'copyImage') copyImage()
else if (cmd === 'reply') replyToMsg()
else if (cmd === 'recall') recallMsg()
else if (cmd === 'delete') deleteMessage({})
@@ -312,18 +385,20 @@ async function recallMsg() {
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
</div>
<img
v-if="imageUrl"
:src="imageUrl"
data-viewer="1"
@click="emit('open-image', imageUrl)"
v-if="thumbUrl"
:src="thumbUrl"
loading="lazy"
@click="openViewer"
/>
<div v-else class="image-fallback">
<div class="image-fallback-name">[图片] {{ (msg.body as any).name }}</div>
<div class="image-fallback-meta">{{ formatSize((msg.body as any).size) }}</div>
<div v-if="thumbState === 'loading'" class="image-fallback-meta">正在生成预览…</div>
<div v-if="hasLocalFile" class="image-fallback-actions">
<el-button size="small" link @click="openLocal">打开</el-button>
<el-button size="small" link @click="revealLocal">在文件夹中显示</el-button>
</div>
</div>
<el-progress
v-if="progress"
:percentage="progressPct"
@@ -337,7 +412,6 @@ async function recallMsg() {
</div>
</div>
</div>
</div>
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
<div class="bubble file-card">
+8
View File
@@ -18,6 +18,14 @@ const self = computed(() => device.self)
function pick(d: { deviceId: string }) {
session.setActive(d.deviceId)
// 重复点同一个设备时 activePeerId 没变, watch 不会触发 -> 这里显式清一次。
// 不然"点开了会话但角标数字还在"。
if (device.unread[d.deviceId]) {
const next = { ...device.unread }
delete next[d.deviceId]
device.unread = next
}
window.api.clearUnread(d.deviceId)
}
function isSelf(id: string) {
+27
View File
@@ -51,3 +51,30 @@ export function formatSize(bytes: number): string {
export function isImage(mime: string): boolean {
return /^image\//i.test(mime)
}
// 常见图片扩展名 -> mime (选中文件时只能拿到路径, 没有 mime)
const IMAGE_MIME_BY_EXT: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
avif: 'image/avif',
ico: 'image/x-icon',
}
/** 按扩展名判断是不是图片 (决定消息类型: image 还是 file) */
export function isImageFileName(name: string): boolean {
const m = /\.([a-z0-9]+)$/i.exec(name || '')
if (!m) return false
return !!IMAGE_MIME_BY_EXT[m[1].toLowerCase()]
}
/** 按扩展名猜图片的 mime; 不是图片就返回空 */
export function imageMimeOf(name: string): string {
const m = /\.([a-z0-9]+)$/i.exec(name || '')
if (!m) return ''
return IMAGE_MIME_BY_EXT[m[1].toLowerCase()] || ''
}
+32
View File
@@ -0,0 +1,32 @@
// 收到一条消息时的未读处理策略 (纯函数, 不依赖 vue/electron, 所以能在无头自测里跑)
//
// 这个策略以前是散在 App.vue 里的 if, 出过一次很典型的 bug:
// "用户正看着当前会话, 但消息没有 savedPath (文字消息就没有) -> 提前 return
// -> 清未读的逻辑永远走不到 -> dock/任务栏角标的数字一直挂着"。
// 抽成函数后, 顺序问题就变成了可以断言的行为。
export type UnreadAction =
/** 用户正看着这个会话: 立刻清未读 + 回已读回执 (不看有没有 savedPath) */
| 'clear'
/** 文件类消息还没落地: 先只显示气泡占位, 等第二帧再算 */
| 'wait'
/** 不在当前会话: 加未读 + 弹提示 */
| 'count'
export function unreadActionForIncoming(opts: {
/** 用户当前是不是正看着这个会话 */
isActive: boolean
/** 文件类消息: 文件落地了没 (文字消息永远是 false) */
hasSavedPath: boolean
}): UnreadAction {
// 注意: 正在看的时候**必须**先清未读, 不能被 hasSavedPath 拦下来 ——
// 文字消息没有 savedPath, 之前就是在这里被提前 return 掉的。
if (opts.isActive) return 'clear'
if (!opts.hasSavedPath) return 'wait'
return 'count'
}
/** 打开/停留在某个会话时, 要不要顺手把它的未读清掉 */
export function shouldClearOnOpen(hasUnread: boolean): boolean {
return hasUnread
}