Files
CipherTalk/electron/services/nativeImageDecrypt.ts
T
ILoveBingLu 6ef395f3c9 发布 4.2.0:优化图片解密与聊天滚动体验
本次提交将应用版本更新到 4.2.0,并同步更新 package-lock、README 版本徽标和 CHANGELOG 发布说明。

主要变更:
- 接入 CipherTalk 自研图片 DAT 原生解密模块,替换原先迁移自 WeFlow 的命名与资源落点。
- 新增 Windows x64 与 macOS arm64 的预编译 native addon 资源,并补充 manifest、检查脚本和同步脚本。
- 保留 native 优先、TypeScript 兜底的图片解密链路,兼容 V3/V4 图片、wxgf 后处理、缓存命中、高清图回退和实况照片提取。
- 优化图片解密服务的缓存校验、wxgf/HEVC 白图规避、耗时诊断和默认日志输出,减少线上噪音。
- 聊天消息列表改为动态高度虚拟列表,卸载屏幕外消息 DOM 与图片节点,降低长会话内存和渲染压力。
- 修复虚拟列表初始挂载时滚底与顶部历史预加载互相打架导致界面上下晃动的问题。
- 顶部历史消息改为接近顶部并向上滚动时提前加载,同时加强 prepend 后的滚动位置恢复。
- 解析图片 XML 中的宽高信息,并用于聊天图片骨架屏、未解密占位、已解密图片和图片查看器初始窗口尺寸。
- 打包清理逻辑改为按当前平台保留对应 native addon,避免安装包携带无关平台产物。

验证:
- 已执行 npx tsc --noEmit,通过 TypeScript 类型检查。
- 本地未执行应用构建,发布构建交由 GitHub Actions 的 tag 发布工作流完成。
2026-04-21 04:44:47 +08:00

156 lines
4.2 KiB
TypeScript

import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
const CURRENT_ADDON_NAME = 'ciphertalk-image-native'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
}
type NativeAddonMetadata = {
name?: string
version?: string
vendor?: string
source?: string
platforms?: string[]
}
let cachedAddon: NativeAddon | null | undefined
let cachedMetadata: NativeAddonMetadata | null | undefined
function shouldEnableNative(): boolean {
return process.env.CIPHERTALK_IMAGE_NATIVE !== '0'
}
function expandAsarCandidates(filePath: string): string[] {
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
return [filePath]
}
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
}
function getPlatformDir(): string {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'macos'
if (process.platform === 'linux') return 'linux'
return process.platform
}
function getArchDir(): string {
if (process.arch === 'x64') return 'x64'
if (process.arch === 'arm64') return 'arm64'
return process.arch
}
function getAddonCandidates(): string[] {
const platformDir = getPlatformDir()
const archDir = getArchDir()
const cwd = process.cwd()
const fileName = `${CURRENT_ADDON_NAME}-${platformDir}-${archDir}.node`
const roots = [
join(cwd, 'resources', 'wedecrypt'),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt'),
join(process.resourcesPath, 'wedecrypt')
]
: [])
]
const candidates = roots.map((root) => join(root, fileName))
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
function loadAddon(): NativeAddon | null {
if (!shouldEnableNative()) return null
if (cachedAddon !== undefined) return cachedAddon
for (const candidate of getAddonCandidates()) {
if (!existsSync(candidate)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require(candidate) as NativeAddon
if (addon && typeof addon.decryptDatNative === 'function') {
cachedAddon = addon
return addon
}
} catch {
// try next candidate
}
}
cachedAddon = null
return null
}
function getMetadataCandidates(): string[] {
const cwd = process.cwd()
const candidates = [
join(cwd, 'resources', 'wedecrypt', 'manifest.json'),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt', 'manifest.json'),
join(process.resourcesPath, 'wedecrypt', 'manifest.json')
]
: [])
]
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
export function nativeAddonMetadata(): NativeAddonMetadata | null {
if (cachedMetadata !== undefined) return cachedMetadata
for (const candidate of getMetadataCandidates()) {
if (!existsSync(candidate)) continue
try {
const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as NativeAddonMetadata
cachedMetadata = parsed
return parsed
} catch {
// try next candidate
}
}
cachedMetadata = null
return null
}
export function nativeAddonLocation(): string | null {
for (const candidate of getAddonCandidates()) {
if (existsSync(candidate)) return candidate
}
return null
}
export function nativeDecryptEnabled(): boolean {
return shouldEnableNative()
}
export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const addon = loadAddon()
if (!addon) return null
try {
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
if (!result || !Buffer.isBuffer(result.data)) return null
const rawExt = typeof result.ext === 'string' && result.ext.trim()
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
return { data: result.data, ext, isWxgf }
} catch {
return null
}
}