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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) })
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 里的历史记录
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -101,19 +101,27 @@ 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++
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string
|
||||
const b64 = dataUrl.split(',')[1]
|
||||
window.api.sendBuffer(props.peer!.deviceId, b64, file.name, file.type)
|
||||
.then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
|
||||
// 图片走文件通道 (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
|
||||
const b64 = dataUrl.split(',')[1]
|
||||
window.api.sendBuffer(props.peer!.deviceId, b64, file.name, file.type)
|
||||
.then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
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 || '')) })
|
||||
|
||||
@@ -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++
|
||||
await readImageAsPending(file)
|
||||
// 有真实路径的图片直接走文件通道 (流式上传 + 缩略图),
|
||||
// 别再把整张图读成 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)
|
||||
|
||||
@@ -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,29 +385,30 @@ 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>
|
||||
<el-progress
|
||||
v-if="progress"
|
||||
:percentage="progressPct"
|
||||
:stroke-width="4"
|
||||
:format="() => ''"
|
||||
class="msg-progress"
|
||||
/>
|
||||
<div v-if="progress" class="msg-progress-meta">
|
||||
<span>{{ formatSize(progress.sent) }} / {{ formatSize(progress.total) }}</span>
|
||||
<span class="msg-progress-speed">{{ speedMBps }} MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress
|
||||
v-if="progress"
|
||||
:percentage="progressPct"
|
||||
:stroke-width="4"
|
||||
:format="() => ''"
|
||||
class="msg-progress"
|
||||
/>
|
||||
<div v-if="progress" class="msg-progress-meta">
|
||||
<span>{{ formatSize(progress.sent) }} / {{ formatSize(progress.total) }}</span>
|
||||
<span class="msg-progress-speed">{{ speedMBps }} MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()] || ''
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user