2b63bf7d0b
## 图片直显 + 右键复制 以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。 根因有两层: 1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的, 对方只能看到文件卡片。 2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`, 大图没有缩略图 -> 渲染层 fallback 成文件卡片。 改法: - 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**, 对端不用再解码, 老版本对端也看得到图。 - 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp: 宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG, 然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。 阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。 源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。 - 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传), 只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。 - 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图** (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。 - 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。 ## 修未读角标不清 用户正看着某个会话, dock/任务栏角标的数字却不消。 根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读), 而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。 文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。 改法: - 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()` (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。 - 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。 - 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。 ## 验证 - npm run typecheck ✅ - npm run build ✅ - npm run selftest ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标) - npm run selftest:sync ✅ 全部通过 - npm run selftest:image ✅ 真 Electron 下验 nativeImage (新增命令) - npm run package:linux ✅ 新 deb - npm run selftest:package ✅ 4/4 真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个), 右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
144 lines
4.8 KiB
JavaScript
144 lines
4.8 KiB
JavaScript
// 无头测试用的 electron 桩: 只提供 src/main 里真正用到的 API
|
|
// (app.getPath / app.getVersion / BrowserWindow / ipcMain / shell ...)
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const fs = require('node:fs')
|
|
|
|
const root = process.env.LNM_SELFTEST_HOME || path.join(os.tmpdir(), 'lnm-selftest')
|
|
fs.mkdirSync(root, { recursive: true })
|
|
|
|
function ensure(p) {
|
|
fs.mkdirSync(p, { recursive: true })
|
|
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,
|
|
getPath(name) {
|
|
if (name === 'userData') return ensure(path.join(root, 'userData'))
|
|
if (name === 'documents') return ensure(path.join(root, 'documents'))
|
|
if (name === 'downloads') return ensure(path.join(root, 'downloads'))
|
|
if (name === 'home') return os.homedir()
|
|
return ensure(path.join(root, name))
|
|
},
|
|
getVersion: () => '0.1.0',
|
|
getName: () => 'local-net-msg',
|
|
getAppPath: () => process.cwd(),
|
|
on: () => {},
|
|
once: () => {},
|
|
whenReady: async () => {},
|
|
quit: () => {},
|
|
},
|
|
BrowserWindow: class BrowserWindow {
|
|
static getAllWindows() { return [] }
|
|
static getFocusedWindow() { return null }
|
|
isMinimized() { return false }
|
|
restore() {}
|
|
show() {}
|
|
focus() {}
|
|
webContents = { send: () => {} }
|
|
},
|
|
// 注意: 故意不提供 ipcRenderer —— electron-store 见到它会以为是渲染进程
|
|
ipcMain: { on: () => {}, once: () => {}, handle: () => {}, removeHandler: () => {} },
|
|
contextBridge: { exposeInMainWorld: () => {} },
|
|
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,
|
|
}
|
|
|