发布 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 发布工作流完成。
This commit is contained in:
ILoveBingLu
2026-04-21 04:44:47 +08:00
parent 4d39117a29
commit bb2e6ef2ff
18 changed files with 1419 additions and 172 deletions

3
.gitignore vendored
View File

@@ -53,7 +53,10 @@ WeFlow
WxKey-CC
upx
native-dlls
native/image-decrypt/
native/image-decrypt/target
resources/whisper
xkey
skills
.claude/
.tmp

View File

@@ -10,6 +10,27 @@
### 变更
- 暂无
## [4.2.0] - 2026-04-21
### 新增
- 新增 CipherTalk 自研图片 DAT 原生解密模块接入,支持 Windows x64 与 macOS arm64 预编译 `.node` 资源,并在打包时按平台保留对应产物。
- 新增图片 native 解密运行时检查与同步脚本,便于验证本机原生模块是否可加载、是否为当前平台正确产物。
- 聊天图片消息新增 XML 宽高解析,支持从 `cdnthumbwidth/cdnthumbheight``cdnmidwidth/cdnmidheight``cdnhdwidth/cdnhdheight` 等字段提取比例信息。
### 优化
- 图片 DAT 解密链路改为 native 优先、TypeScript 兜底,保留原有 V3/V4 解密兼容路径、wxgf 后处理、缓存命中、高清图回退和实况照片提取逻辑。
- 聊天图片解密中、未解密、未配置密钥和已解密状态统一按图片比例渲染占位,减少图片加载前后的布局跳动。
- 图片查看器打开时会参考消息中的图片宽高预设窗口尺寸,图片真实加载后继续按实际尺寸校正。
- 聊天消息列表改为动态高度虚拟列表,屏幕外消息 DOM 和图片节点会自动卸载,降低长会话滚动时的内存与渲染压力。
- 顶部历史消息改为接近顶部并向上滚动时提前加载,同时加入滚动锚点恢复,减少加载更早消息后的跳屏。
- 优化聊天滚动事件更新频率,避免每次滚动都触发不必要的 React 状态更新。
### 修复
- 修复虚拟列表初始挂载时被误判为滚到顶部,导致打开聊天后历史加载与滚底逻辑互相打架、界面上下晃动的问题。
- 修复部分 wxgf/HEVC 图片解码后出现纯白图的问题,避免错误缓存影响后续显示。
- 修复图片 native 解密调试日志和聊天表匹配日志默认过多输出的问题,改为仅在调试环境变量启用时打印。
- 修复图片 DAT 路径搜索、缓存检查与写入耗时较高时缺少定位信息的问题,保留可按需开启的耗时诊断。
## [4.1.9] - 2026-04-11
### 修复

View File

@@ -7,7 +7,7 @@
**一款现代化的微信聊天记录查看与分析工具**
[![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-4.1.8-green.svg)](package.json)
[![Version](https://img.shields.io/badge/version-4.2.0-green.svg)](package.json)
[![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]()
[![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]()
[![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]()

View File

@@ -2243,8 +2243,7 @@ function registerIpcHandlers() {
ipcMain.handle('imageDecrypt:decryptImage', async (_, inputPath: string, outputPath: string, xorKey: number, aesKey?: string) => {
try {
logService?.info('ImageDecrypt', '开始解密图片', { inputPath, outputPath })
const aesKeyBuffer = aesKey ? imageDecryptService.asciiKey16(aesKey) : undefined
await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKeyBuffer)
await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKey)
logService?.info('ImageDecrypt', '图片解密成功', { outputPath })
return { success: true }
} catch (e) {

View File

@@ -892,7 +892,7 @@ class ChatService extends EventEmitter {
}
}
if (tables.length > 0) {
if (tables.length > 0 && process.env.CIPHERTALK_CHAT_DEBUG === '1') {
const sample = tables.slice(0, 8).map(t => t.name).join(', ')
console.warn(`[ChatService] 未匹配到消息表: session=${sessionId}, hash=${hash}, tables=${tables.length}, sample=[${sample}]`)
}

View File

@@ -1042,7 +1042,7 @@ class DataManagementService {
let successCount = 0
let failCount = 0
const totalFiles = pendingImages.length
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : Buffer.alloc(16)
const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined
// 分批处理,每批 50 个,避免内存溢出
const BATCH_SIZE = 50
@@ -1067,7 +1067,7 @@ class DataManagementService {
const outputRelativePath = relativePath.replace(/\.dat$/, '')
// 解密图片
const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyBuffer)
const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyText)
// 检测图片格式
const ext = this.detectImageFormat(decrypted)
@@ -1213,14 +1213,14 @@ class DataManagementService {
const outputRelativePath = relativePath.replace(/\.dat$/, '')
// 解密图片
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : undefined
const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined
console.log('解密图片:', filePath)
console.log('XOR Key:', xorKey.toString(16))
console.log('AES Key String:', aesKeyStr)
console.log('AES Key Buffer:', aesKeyBuffer?.toString('hex'))
console.log('图片版本:', imageDecryptService.getDatVersion(filePath))
const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyBuffer || Buffer.alloc(16))
const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyText)
// 检测图片格式
const ext = this.detectImageFormat(decrypted)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
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
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ciphertalk",
"version": "4.1.8",
"version": "4.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ciphertalk",
"version": "4.1.8",
"version": "4.2.0",
"hasInstallScript": true,
"license": "CC-BY-NC-SA-4.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "ciphertalk",
"version": "4.1.9",
"version": "4.2.0",
"description": "密语 - 微信聊天记录查看工具",
"author": "ILoveBingLu",
"license": "CC-BY-NC-SA-4.0",
@@ -10,6 +10,11 @@
"icon:mac": "bash scripts/build-macos-icon.sh",
"native:macos": "bash native-dlls/build-macos.sh",
"native:macos:check": "node scripts/check-macos-native.js",
"native:image:check": "cargo check --manifest-path native/image-decrypt/Cargo.toml",
"native:image:build": "cargo build --manifest-path native/image-decrypt/Cargo.toml --release",
"native:image:check:current": "node scripts/check-image-native.cjs",
"native:image:sync": "node scripts/sync-image-native.cjs",
"native:image:build:sync": "npm run native:image:build && npm run native:image:sync",
"build:prepare:mac": "node scripts/patch-dmg-builder.cjs",
"build:prepare": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
@@ -214,6 +219,8 @@
"node_modules/sherpa-onnx-node/**/*",
"node_modules/koffi/**/*",
"dist-electron/workers/**/*",
"resources/wedecrypt/*.node",
"resources/wedecrypt/**/*.node",
"resources/**/*"
]
}

View File

@@ -0,0 +1,14 @@
{
"name": "ciphertalk-image-native",
"version": "source-present-selfbuilt",
"vendor": "CipherTalk",
"source": "native/image-decrypt",
"activeBinaries": {
"win32-x64": "self-built-from-repo-source",
"macos-arm64": "self-built-from-repo-source"
},
"platforms": [
"win32-x64",
"macos-arm64"
]
}

View File

@@ -0,0 +1,43 @@
const fs = require('node:fs')
const path = require('node:path')
const rootDir = path.resolve(__dirname, '..')
const baseDir = path.join(rootDir, 'resources', 'wedecrypt')
const addonName = 'ciphertalk-image-native'
function resolvePlatformDir(value = process.platform) {
if (value === 'win32') return 'win32'
if (value === 'darwin' || value === 'macos') return 'macos'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported platform: ${value}`)
}
function resolveArchDir(value = process.arch) {
if (value === 'x64') return 'x64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported arch: ${value}`)
}
function main() {
const platformDir = resolvePlatformDir(process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform)
const archDir = resolveArchDir(process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch)
const addonPath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`)
console.log(`[image-native-check] target: ${platformDir}/${archDir}`)
console.log(`[image-native-check] addon path: ${addonPath}`)
if (!fs.existsSync(addonPath)) {
console.error('[image-native-check] missing image native addon')
process.exit(2)
}
const stat = fs.statSync(addonPath)
if (!stat.isFile() || stat.size <= 0) {
console.error('[image-native-check] invalid image native addon')
process.exit(3)
}
console.log(`[image-native-check] ok (${stat.size} bytes)`)
}
main()

View File

@@ -3,6 +3,7 @@ const path = require('path')
const rootDir = path.resolve(__dirname, '..')
const macosDir = path.join(rootDir, 'resources', 'macos')
const imageNativeBaseDir = path.join(rootDir, 'resources', 'wedecrypt')
const requiredArtifacts = [
{ name: 'libwx_key.dylib', type: 'file', generated: true },
@@ -65,6 +66,20 @@ function main() {
process.exit(2)
}
const imageNativeArch = process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch
const imageNativeAddon = path.join(
imageNativeBaseDir,
`ciphertalk-image-native-macos-${imageNativeArch}.node`
)
const imageNativeStat = statSafe(imageNativeAddon)
if (!imageNativeStat || !imageNativeStat.isFile()) {
console.error(`[macos-native-check] missing image native addon: ${imageNativeAddon}`)
process.exit(3)
}
console.log(`[macos-native-check] image native addon ok: ${imageNativeAddon} (${imageNativeStat.size} bytes)`)
console.log('[macos-native-check] all required macOS native artifacts are present')
}

View File

@@ -1,12 +1,92 @@
const fs = require('fs');
const path = require('path');
const { Arch } = require('electron-builder');
const IMAGE_NATIVE_PREFIX = 'ciphertalk-image-native-';
const IMAGE_NATIVE_SUFFIX = '.node';
function resolveNativePlatform(electronPlatformName) {
if (electronPlatformName === 'darwin') return 'macos';
if (electronPlatformName === 'win32') return 'win32';
if (electronPlatformName === 'linux') return 'linux';
return electronPlatformName;
}
function resolveNativeArch(arch) {
if (typeof arch === 'string') return arch;
if (typeof arch === 'number' && Arch[arch]) return Arch[arch];
return process.arch;
}
function uniqueExistingDirs(candidates) {
return Array.from(new Set(candidates)).filter((targetPath) => fs.existsSync(targetPath));
}
function rewriteNativeManifest(manifestPath, targetKey) {
if (!fs.existsSync(manifestPath)) return;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const nextActiveBinaries = {};
if (manifest.activeBinaries && manifest.activeBinaries[targetKey]) {
nextActiveBinaries[targetKey] = manifest.activeBinaries[targetKey];
}
manifest.activeBinaries = nextActiveBinaries;
manifest.platforms = Object.keys(nextActiveBinaries);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`已收敛 image native manifest 到当前平台: ${targetKey}`);
} catch (error) {
console.warn(`收敛 image native manifest 失败: ${manifestPath}`, error);
}
}
function pruneImageNativeAddons(context) {
const platformDir = resolveNativePlatform(context.electronPlatformName);
const archDir = resolveNativeArch(context.arch);
const targetFileName = `${IMAGE_NATIVE_PREFIX}${platformDir}-${archDir}${IMAGE_NATIVE_SUFFIX}`;
const targetKey = `${platformDir}-${archDir}`;
const productName = context.packager?.appInfo?.productFilename || 'CipherTalk';
const resourceRoots = uniqueExistingDirs([
path.join(context.appOutDir, 'resources'),
path.join(context.appOutDir, 'Contents', 'Resources'),
path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources')
]);
for (const resourceRoot of resourceRoots) {
for (const nativeDir of [
path.join(resourceRoot, 'resources', 'wedecrypt'),
path.join(resourceRoot, 'wedecrypt')
]) {
if (!fs.existsSync(nativeDir)) continue;
const nativeFiles = fs.readdirSync(nativeDir)
.filter((file) => file.startsWith(IMAGE_NATIVE_PREFIX) && file.endsWith(IMAGE_NATIVE_SUFFIX));
if (nativeFiles.length === 0) continue;
if (!nativeFiles.includes(targetFileName)) {
console.warn(`未找到当前平台 image native addon跳过裁剪: ${targetFileName}`);
continue;
}
let deletedCount = 0;
for (const file of nativeFiles) {
if (file === targetFileName) continue;
fs.rmSync(path.join(nativeDir, file), { force: true });
deletedCount++;
}
rewriteNativeManifest(path.join(nativeDir, 'manifest.json'), targetKey);
console.log(`已裁剪 image native addon仅保留 ${targetFileName},删除 ${deletedCount} 个无关文件。`);
}
}
}
exports.default = async function (context) {
// context.appOutDir 是打包后的临时解压目录
const localesDir = path.join(context.appOutDir, 'locales');
if (fs.existsSync(localesDir)) {
console.log('🧹 正在清理多余的 Chromium 语言包...');
console.log('正在清理多余的 Chromium 语言包...');
const files = fs.readdirSync(localesDir);
// 只保留中文(简体/繁体)和英文
@@ -22,9 +102,11 @@ exports.default = async function (context) {
deletedCount++;
}
}
console.log(`已删除 ${deletedCount} 个无关语言包,仅保留中英文。`);
console.log(`已删除 ${deletedCount} 个无关语言包,仅保留中英文。`);
}
pruneImageNativeAddons(context);
if (context.electronPlatformName === 'darwin') {
const productName = context.packager?.appInfo?.productFilename || 'CipherTalk';
const launcherCandidates = [
@@ -35,7 +117,7 @@ exports.default = async function (context) {
for (const launcherPath of launcherCandidates) {
if (!fs.existsSync(launcherPath)) continue;
fs.chmodSync(launcherPath, 0o755);
console.log(`已确保 macOS MCP 启动器可执行: ${launcherPath}`);
console.log(`已确保 macOS MCP 启动器可执行: ${launcherPath}`);
break;
}
}

View File

@@ -0,0 +1,126 @@
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const crateRoot = path.join(projectRoot, 'native', 'image-decrypt')
const releaseDir = path.join(crateRoot, 'target', 'release')
const addonName = 'ciphertalk-image-native'
function parseArgs(argv) {
const parsed = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (!arg.startsWith('--')) continue
const key = arg.slice(2)
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
parsed[key] = '1'
continue
}
parsed[key] = next
i += 1
}
return parsed
}
function resolvePlatformDir(value = process.platform) {
if (value === 'win32') return 'win32'
if (value === 'darwin' || value === 'macos') return 'macos'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported platform: ${value}`)
}
function resolveArchDir(value = process.arch) {
if (value === 'x64') return 'x64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported arch: ${value}`)
}
function resolveBuiltLibrary(platformDir, customLibPath) {
if (customLibPath) {
return path.resolve(projectRoot, customLibPath)
}
if (platformDir === 'win32') {
return path.join(releaseDir, 'ciphertalk_image_native.dll')
}
if (platformDir === 'macos') {
return path.join(releaseDir, 'libciphertalk_image_native.dylib')
}
if (platformDir === 'linux') {
return path.join(releaseDir, 'libciphertalk_image_native.so')
}
throw new Error(`Unsupported platform: ${platformDir}`)
}
function removeLegacyOutput(platformDir, archDir, outputName) {
const legacyDir = path.join(projectRoot, 'resources', 'wedecrypt', platformDir, archDir)
const legacyPath = path.join(legacyDir, outputName)
if (fs.existsSync(legacyPath)) {
fs.rmSync(legacyPath, { force: true })
}
if (fs.existsSync(legacyDir) && fs.readdirSync(legacyDir).length === 0) {
fs.rmSync(legacyDir, { recursive: true, force: true })
}
const platformDirPath = path.join(projectRoot, 'resources', 'wedecrypt', platformDir)
if (fs.existsSync(platformDirPath) && fs.readdirSync(platformDirPath).length === 0) {
fs.rmSync(platformDirPath, { recursive: true, force: true })
}
}
function buildManifest() {
const baseDir = path.join(projectRoot, 'resources', 'wedecrypt')
const matrix = [
['win32', 'x64'],
['win32', 'arm64'],
['macos', 'x64'],
['macos', 'arm64'],
['linux', 'x64'],
['linux', 'arm64']
]
const activeBinaries = {}
const platforms = []
for (const [platformDir, archDir] of matrix) {
const filePath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`)
if (!fs.existsSync(filePath)) continue
const key = `${platformDir}-${archDir}`
activeBinaries[key] = 'self-built-from-repo-source'
platforms.push(key)
}
const manifest = {
name: addonName,
version: 'source-present-selfbuilt',
vendor: 'CipherTalk',
source: 'native/image-decrypt',
activeBinaries,
platforms
}
fs.mkdirSync(baseDir, { recursive: true })
fs.writeFileSync(path.join(baseDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
}
function main() {
const args = parseArgs(process.argv.slice(2))
const platformDir = resolvePlatformDir(args.platform || process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform)
const archDir = resolveArchDir(args.arch || process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch)
const builtLibrary = resolveBuiltLibrary(platformDir, args.lib || process.env.CIPHERTALK_IMAGE_NATIVE_LIB)
if (!fs.existsSync(builtLibrary)) {
throw new Error(`Built library not found: ${builtLibrary}`)
}
const outputDir = path.join(projectRoot, 'resources', 'wedecrypt')
const outputName = `${addonName}-${platformDir}-${archDir}.node`
const outputPath = path.join(outputDir, outputName)
fs.mkdirSync(outputDir, { recursive: true })
fs.copyFileSync(builtLibrary, outputPath)
removeLegacyOutput(platformDir, archDir, outputName)
buildManifest()
console.log(`[sync-image-native] synced ${builtLibrary} -> ${outputPath}`)
}
main()

View File

@@ -270,6 +270,10 @@ function ChatPage(_props: ChatPageProps) {
const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef<Message[]>([])
const isLoadingMoreRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollToBottomAfterRenderRef = useRef(false)
const scrollRestoreTimersRef = useRef<number[]>([])
const currentSessionIdRef = useRef<string | null>(null)
const lastUpdateTimeRef = useRef<number>(0)
const updateTimerRef = useRef<NodeJS.Timeout | null>(null)
@@ -354,6 +358,87 @@ function ChatPage(_props: ChatPageProps) {
setTimeout(() => setTopToast(null), 2000)
}, [])
useEffect(() => {
isLoadingMoreRef.current = isLoadingMore
}, [isLoadingMore])
const getMessageDomKey = useCallback((message: Message): string => {
return [
message.serverId ?? '',
message.localId ?? '',
message.createTime ?? '',
message.sortSeq ?? ''
].join('-')
}, [])
const findMessageWrapperByKey = useCallback((listEl: HTMLElement, key: string): HTMLElement | null => {
const wrappers = Array.from(listEl.querySelectorAll<HTMLElement>('.message-wrapper[data-message-key]'))
return wrappers.find(el => el.dataset.messageKey === key) || null
}, [])
const captureScrollAnchor = useCallback((): { key: string; top: number } | null => {
const listEl = messageListRef.current
if (!listEl) return null
const listRect = listEl.getBoundingClientRect()
const wrappers = Array.from(listEl.querySelectorAll<HTMLElement>('.message-wrapper[data-message-key]'))
const anchorEl = wrappers.find((el) => {
const rect = el.getBoundingClientRect()
return rect.bottom >= listRect.top + 12
})
const key = anchorEl?.dataset.messageKey
if (!anchorEl || !key) return null
return {
key,
top: anchorEl.getBoundingClientRect().top - listRect.top
}
}, [])
const clearScrollRestoreTimers = useCallback(() => {
for (const timer of scrollRestoreTimersRef.current) {
window.clearTimeout(timer)
}
scrollRestoreTimersRef.current = []
}, [])
const restoreScrollAnchor = useCallback((anchor: { key: string; top: number } | null) => {
if (!anchor) return
clearScrollRestoreTimers()
const restore = () => {
const listEl = messageListRef.current
if (!listEl) return
const anchorEl = findMessageWrapperByKey(listEl, anchor.key)
if (!anchorEl) return
const listTop = listEl.getBoundingClientRect().top
const currentTop = anchorEl.getBoundingClientRect().top - listTop
const delta = currentTop - anchor.top
if (Math.abs(delta) > 1) {
listEl.scrollTop += delta
}
}
requestAnimationFrame(() => {
restore()
scrollRestoreTimersRef.current = [
window.setTimeout(() => {
restore()
isLoadingMoreRef.current = false
}, 150)
]
})
}, [clearScrollRestoreTimers, findMessageWrapperByKey])
useEffect(() => {
return () => {
clearScrollRestoreTimers()
}
}, [clearScrollRestoreTimers])
const copyText = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text || '')
@@ -568,11 +653,12 @@ function ChatPage(_props: ChatPageProps) {
// 标记用户正在操作(首次加载)
isUserOperatingRef.current = true
} else {
if (isLoadingMoreRef.current) return
isLoadingMoreRef.current = true
setLoadingMore(true)
}
// 记录加载前的第一条消息元素
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
const anchor = offset > 0 ? captureScrollAnchor() : null
try {
// 确保连接已建立(如果未连接,先连接)
@@ -590,18 +676,10 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
// 首次加载滚动到底部 (瞬间)
requestAnimationFrame(() => {
scrollToBottom(false)
})
scrollToBottomAfterRenderRef.current = true
} else {
appendMessages(result.messages, true)
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
if (firstMsgEl && listEl) {
requestAnimationFrame(() => {
listEl.scrollTop = firstMsgEl.offsetTop - 80
})
}
restoreScrollAnchor(anchor)
}
setHasMoreMessages(result.hasMore ?? false)
setCurrentOffset(offset + result.messages.length)
@@ -611,6 +689,9 @@ function ChatPage(_props: ChatPageProps) {
} finally {
setLoadingMessages(false)
setLoadingMore(false)
if (offset > 0) {
isLoadingMoreRef.current = false
}
// 加载完成后,延迟重置用户操作标记(给一点缓冲时间)
if (offset === 0) {
setTimeout(() => {
@@ -714,11 +795,11 @@ function ChatPage(_props: ChatPageProps) {
// 滚动加载更多 + 显示/隐藏回到底部按钮
const loadMoreMessagesInDateJumpMode = useCallback(async () => {
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMore || !hasMoreMessages) return
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMoreRef.current || !hasMoreMessages) return
const listEl = messageListRef.current
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
const anchor = captureScrollAnchor()
isLoadingMoreRef.current = true
setLoadingMore(true)
try {
const result = await window.electronAPI.chat.getMessagesBefore(
@@ -756,11 +837,7 @@ function ChatPage(_props: ChatPageProps) {
setHasMoreMessages(result.hasMore ?? false)
}
if (firstMsgEl && listEl) {
requestAnimationFrame(() => {
listEl.scrollTop = firstMsgEl.offsetTop - 80
})
}
restoreScrollAnchor(anchor)
} else {
setHasMoreMessages(false)
}
@@ -768,22 +845,24 @@ function ChatPage(_props: ChatPageProps) {
console.error('日期跳转模式加载更多失败:', e)
} finally {
setLoadingMore(false)
isLoadingMoreRef.current = false
}
}, [
currentSessionId,
dateJumpCursorSortSeq,
dateJumpCursorCreateTime,
dateJumpCursorLocalId,
isLoadingMore,
hasMoreMessages,
appendMessages,
captureScrollAnchor,
restoreScrollAnchor,
setHasMoreMessages,
setLoadingMore
])
// 日期跳转模式:向下滑动加载更新的消息
const loadMoreMessagesAfterInDateJumpMode = useCallback(async () => {
if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMore || !hasMoreMessagesAfter) return
if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMoreRef.current || !hasMoreMessagesAfter) return
const listEl = messageListRef.current
if (!listEl) return
@@ -792,6 +871,7 @@ function ChatPage(_props: ChatPageProps) {
const oldScrollHeight = listEl.scrollHeight
const oldScrollTop = listEl.scrollTop
isLoadingMoreRef.current = true
setLoadingMore(true)
try {
const result = await window.electronAPI.chat.getMessagesAfter(
@@ -845,13 +925,13 @@ function ChatPage(_props: ChatPageProps) {
console.error('日期跳转模式向下加载失败:', e)
} finally {
setLoadingMore(false)
isLoadingMoreRef.current = false
}
}, [
currentSessionId,
dateJumpCursorSortSeqEnd,
dateJumpCursorCreateTimeEnd,
dateJumpCursorLocalIdEnd,
isLoadingMore,
hasMoreMessagesAfter,
appendMessages
])
@@ -860,17 +940,19 @@ function ChatPage(_props: ChatPageProps) {
if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
const isScrollingUp = scrollTop < lastScrollTopRef.current - 4
lastScrollTopRef.current = scrollTop
// 显示回到底部按钮:距离底部超过 300px
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollToBottom(distanceFromBottom > 300)
if (!isLoadingMore && currentSessionId) {
const topThreshold = clientHeight * 0.3
if (!isLoadingMoreRef.current && currentSessionId) {
const topThreshold = Math.max(clientHeight * 2, 1200)
const bottomThreshold = clientHeight * 0.3
// 向上滑动:加载更早的消息
if (scrollTop < topThreshold && hasMoreMessages) {
// 向上滑动:提前加载更早的消息,不等用户真正顶到顶部
if (isScrollingUp && scrollTop < topThreshold && hasMoreMessages) {
if (isDateJumpMode) {
loadMoreMessagesInDateJumpMode()
} else {
@@ -884,7 +966,6 @@ function ChatPage(_props: ChatPageProps) {
}
}
}, [
isLoadingMore,
hasMoreMessages,
hasMoreMessagesAfter,
currentSessionId,
@@ -897,20 +978,27 @@ function ChatPage(_props: ChatPageProps) {
// 滚动到底部
const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => {
if (messageListRef.current) {
// 如果传入的是事件对象,默认为 smooth
const isSmooth = typeof smooth === 'boolean' ? smooth : true;
if (isSmooth) {
messageListRef.current.scrollTo({
top: messageListRef.current.scrollHeight,
behavior: 'smooth'
})
messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' })
} else {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
}
}, [])
// Scroll to bottom after initial message render
useEffect(() => {
if (scrollToBottomAfterRenderRef.current) {
scrollToBottomAfterRenderRef.current = false
requestAnimationFrame(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
}
}, [messages])
// 日期跳转处理
const handleJumpToDate = useCallback(async () => {
if (!selectedDate || !currentSessionId || isJumpingToDate) return
@@ -1963,8 +2051,14 @@ function ChatPage(_props: ChatPageProps) {
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageDomKey = getMessageDomKey(msg)
return (
<div key={msg.localId} className={`message-wrapper ${wrapperClass}`}>
<div
key={messageDomKey}
className={`message-wrapper ${wrapperClass}`}
data-message-key={messageDomKey}
>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
@@ -2993,6 +3087,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
useEffect(() => {
if (!isImage || !imageContainerRef.current) return
const scrollRoot = imageContainerRef.current.closest('.message-list') as HTMLElement | null
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
@@ -3003,7 +3098,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
})
},
{
rootMargin: '1200px 0px', // 提前加载,减少滚动到位后的等待
root: scrollRoot,
rootMargin: '2800px 0px', // 提前约数屏预热,向上滚动时先解密上方图片
threshold: 0
}
)