mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
@@ -50,6 +50,9 @@ export interface Message {
|
|||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
emojiLocalPath?: string // 本地缓存 castle 路径
|
emojiLocalPath?: string // 本地缓存 castle 路径
|
||||||
|
emojiThumbUrl?: string
|
||||||
|
emojiEncryptUrl?: string
|
||||||
|
emojiAesKey?: string
|
||||||
// 引用消息相关
|
// 引用消息相关
|
||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
@@ -1151,6 +1154,9 @@ class ChatService {
|
|||||||
const emojiInfo = this.parseEmojiInfo(content)
|
const emojiInfo = this.parseEmojiInfo(content)
|
||||||
emojiCdnUrl = emojiInfo.cdnUrl
|
emojiCdnUrl = emojiInfo.cdnUrl
|
||||||
emojiMd5 = emojiInfo.md5
|
emojiMd5 = emojiInfo.md5
|
||||||
|
cdnThumbUrl = emojiInfo.thumbUrl // 复用 cdnThumbUrl 字段或使用 emojiThumbUrl
|
||||||
|
// 注意:Message 接口定义的 emojiThumbUrl,这里我们统一一下
|
||||||
|
// 如果 Message 接口有 emojiThumbUrl,则使用它
|
||||||
} else if (localType === 3 && content) {
|
} else if (localType === 3 && content) {
|
||||||
const imageInfo = this.parseImageInfo(content)
|
const imageInfo = this.parseImageInfo(content)
|
||||||
imageMd5 = imageInfo.md5
|
imageMd5 = imageInfo.md5
|
||||||
@@ -1373,7 +1379,7 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* 解析表情包信息
|
* 解析表情包信息
|
||||||
*/
|
*/
|
||||||
private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string } {
|
private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string; thumbUrl?: string; encryptUrl?: string; aesKey?: string } {
|
||||||
try {
|
try {
|
||||||
// 提取 cdnurl
|
// 提取 cdnurl
|
||||||
let cdnUrl: string | undefined
|
let cdnUrl: string | undefined
|
||||||
@@ -1387,26 +1393,39 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有 cdnurl,尝试 thumburl
|
// 提取 thumburl
|
||||||
if (!cdnUrl) {
|
let thumbUrl: string | undefined
|
||||||
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
||||||
if (thumbUrlMatch) {
|
if (thumbUrlMatch) {
|
||||||
cdnUrl = thumbUrlMatch[1].replace(/&/g, '&')
|
thumbUrl = thumbUrlMatch[1].replace(/&/g, '&')
|
||||||
if (cdnUrl.includes('%')) {
|
if (thumbUrl.includes('%')) {
|
||||||
try {
|
try {
|
||||||
cdnUrl = decodeURIComponent(cdnUrl)
|
thumbUrl = decodeURIComponent(thumbUrl)
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 md5
|
// 提取 md5
|
||||||
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content)
|
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content)
|
||||||
const md5 = md5Match ? md5Match[1] : undefined
|
const md5 = md5Match ? md5Match[1] : undefined
|
||||||
|
|
||||||
// 不构造假 URL,只返回真正的 cdnurl
|
// 提取 encrypturl
|
||||||
// 没有 cdnUrl 时保持静默,交由后续回退逻辑处理
|
let encryptUrl: string | undefined
|
||||||
return { cdnUrl, md5 }
|
const encryptUrlMatch = /encrypturl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /encrypturl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
||||||
|
if (encryptUrlMatch) {
|
||||||
|
encryptUrl = encryptUrlMatch[1].replace(/&/g, '&')
|
||||||
|
if (encryptUrl.includes('%')) {
|
||||||
|
try {
|
||||||
|
encryptUrl = decodeURIComponent(encryptUrl)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 aeskey
|
||||||
|
const aesKeyMatch = /aeskey\s*=\s*['"]([a-zA-Z0-9]+)['"]/i.exec(content) || /aeskey\s*=\s*([a-zA-Z0-9]+)/i.exec(content)
|
||||||
|
const aesKey = aesKeyMatch ? aesKeyMatch[1] : undefined
|
||||||
|
|
||||||
|
return { cdnUrl, md5, thumbUrl, encryptUrl, aesKey }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ChatService] 表情包解析失败:', e, { xml: content })
|
console.error('[ChatService] 表情包解析失败:', e, { xml: content })
|
||||||
return {}
|
return {}
|
||||||
@@ -2622,11 +2641,7 @@ class ChatService {
|
|||||||
// 检查内存缓存
|
// 检查内存缓存
|
||||||
const cached = emojiCache.get(cacheKey)
|
const cached = emojiCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
// 读取文件并转为 data URL
|
return { success: true, localPath: cached }
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否正在下载
|
// 检查是否正在下载
|
||||||
@@ -2634,10 +2649,7 @@ class ChatService {
|
|||||||
if (downloading) {
|
if (downloading) {
|
||||||
const result = await downloading
|
const result = await downloading
|
||||||
if (result) {
|
if (result) {
|
||||||
const dataUrl = this.fileToDataUrl(result)
|
return { success: true, localPath: result }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { success: false, error: '下载失败' }
|
return { success: false, error: '下载失败' }
|
||||||
}
|
}
|
||||||
@@ -2654,10 +2666,7 @@ class ChatService {
|
|||||||
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
emojiCache.set(cacheKey, filePath)
|
emojiCache.set(cacheKey, filePath)
|
||||||
const dataUrl = this.fileToDataUrl(filePath)
|
return { success: true, localPath: filePath }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2671,10 +2680,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (localPath) {
|
if (localPath) {
|
||||||
emojiCache.set(cacheKey, localPath)
|
emojiCache.set(cacheKey, localPath)
|
||||||
const dataUrl = this.fileToDataUrl(localPath)
|
return { success: true, localPath }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { success: false, error: '下载失败' }
|
return { success: false, error: '下载失败' }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -3917,6 +3923,13 @@ class ChatService {
|
|||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
Object.assign(msg, imgInfo)
|
Object.assign(msg, imgInfo)
|
||||||
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
|
} else if (msg.localType === 47) { // Emoji
|
||||||
|
const emojiInfo = this.parseEmojiInfo(rawContent)
|
||||||
|
msg.emojiCdnUrl = emojiInfo.cdnUrl
|
||||||
|
msg.emojiMd5 = emojiInfo.md5
|
||||||
|
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
||||||
|
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
||||||
|
msg.emojiAesKey = emojiInfo.aesKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
@@ -4227,6 +4240,34 @@ class ChatService {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载表情包文件(用于导出,返回文件路径)
|
||||||
|
*/
|
||||||
|
async downloadEmojiFile(msg: Message): Promise<string | null> {
|
||||||
|
if (!msg.emojiMd5) return null
|
||||||
|
let url = msg.emojiCdnUrl
|
||||||
|
|
||||||
|
// 尝试获取 URL
|
||||||
|
if (!url && msg.emojiEncryptUrl) {
|
||||||
|
console.warn('[ChatService] Emoji has only encryptUrl:', msg.emojiMd5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
await this.fallbackEmoticon(msg)
|
||||||
|
url = msg.emojiCdnUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
// Reuse existing downloadEmoji method
|
||||||
|
const result = await this.downloadEmoji(url, msg.emojiMd5)
|
||||||
|
if (result.success && result.localPath) {
|
||||||
|
return result.localPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatService = new ChatService()
|
export const chatService = new ChatService()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
weixinDllPath: string
|
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
lastSession: string
|
lastSession: string
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export class ConfigService {
|
|||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
wxidConfigs: {},
|
wxidConfigs: {},
|
||||||
cachePath: '',
|
cachePath: '',
|
||||||
weixinDllPath: '',
|
|
||||||
lastOpenedDb: '',
|
lastOpenedDb: '',
|
||||||
lastSession: '',
|
lastSession: '',
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
|||||||
@@ -1479,49 +1479,30 @@ class ExportService {
|
|||||||
fs.mkdirSync(emojisDir, { recursive: true })
|
fs.mkdirSync(emojisDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用消息对象中已提取的字段
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const emojiUrl = msg.emojiCdnUrl
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
const emojiMd5 = msg.emojiMd5
|
|
||||||
|
|
||||||
if (!emojiUrl && !emojiMd5) {
|
|
||||||
|
|
||||||
|
if (!localPath || !fs.existsSync(localPath)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定目标文件名
|
||||||
|
const ext = path.extname(localPath) || '.gif'
|
||||||
const key = emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
// 根据 URL 判断扩展名
|
|
||||||
let ext = '.gif'
|
|
||||||
if (emojiUrl) {
|
|
||||||
if (emojiUrl.includes('.png')) ext = '.png'
|
|
||||||
else if (emojiUrl.includes('.jpg') || emojiUrl.includes('.jpeg')) ext = '.jpg'
|
|
||||||
}
|
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
|
|
||||||
// 如果已存在则跳过
|
// 复制文件到导出目录 (如果不存在)
|
||||||
if (fs.existsSync(destPath)) {
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(localPath, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
kind: 'emoji'
|
kind: 'emoji'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 下载表情
|
|
||||||
if (emojiUrl) {
|
|
||||||
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
|
||||||
if (downloaded) {
|
|
||||||
return {
|
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
|
||||||
kind: 'emoji'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('ExportService: exportEmoji failed', e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,23 +99,29 @@ export class Isaac64 {
|
|||||||
this.isaac64();
|
this.isaac64();
|
||||||
this.randcnt = 256;
|
this.randcnt = 256;
|
||||||
}
|
}
|
||||||
return this.randrsl[256 - (this.randcnt--)];
|
return this.randrsl[--this.randcnt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a keystream of the specified size (in bytes).
|
* Generates a keystream where each 64-bit block is Big-Endian.
|
||||||
* @param size Size of the keystream in bytes (must be multiple of 8)
|
* This matches WeChat's behavior (Reverse index order + byte reversal).
|
||||||
* @returns Buffer containing the keystream
|
|
||||||
*/
|
*/
|
||||||
public generateKeystream(size: number): Buffer {
|
public generateKeystreamBE(size: number): Buffer {
|
||||||
const stream = new BigUint64Array(size / 8);
|
const buffer = Buffer.allocUnsafe(size);
|
||||||
for (let i = 0; i < stream.length; i++) {
|
const fullBlocks = Math.floor(size / 8);
|
||||||
stream[i] = this.getNext();
|
|
||||||
|
for (let i = 0; i < fullBlocks; i++) {
|
||||||
|
buffer.writeBigUInt64BE(this.getNext(), i * 8);
|
||||||
}
|
}
|
||||||
// WeChat's logic specifically reverses the entire byte array
|
|
||||||
const buffer = Buffer.from(stream.buffer);
|
const remaining = size % 8;
|
||||||
// 注意:根据 worker.html 的逻辑,它是对 Uint8Array 执行 reverse()
|
if (remaining > 0) {
|
||||||
// Array.from(wasmArray).reverse()
|
const lastK = this.getNext();
|
||||||
return buffer.reverse();
|
const temp = Buffer.allocUnsafe(8);
|
||||||
|
temp.writeBigUInt64BE(lastK, 0);
|
||||||
|
temp.copy(buffer, fullBlocks * 8, 0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,7 +292,6 @@ class SnsService {
|
|||||||
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
|
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
|
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
|
||||||
console.log(`[SnsService] 开始下载视频到临时文件: ${tmpPath}`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
@@ -325,7 +324,6 @@ class SnsService {
|
|||||||
|
|
||||||
fileStream.on('finish', async () => {
|
fileStream.on('finish', async () => {
|
||||||
fileStream.close()
|
fileStream.close()
|
||||||
console.log(`[SnsService] 视频下载完成,开始解密... Key: ${key}`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedBuffer = await readFile(tmpPath)
|
const encryptedBuffer = await readFile(tmpPath)
|
||||||
@@ -334,7 +332,6 @@ class SnsService {
|
|||||||
|
|
||||||
if (key && String(key).trim().length > 0) {
|
if (key && String(key).trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
console.log(`[SnsService] 使用 WASM Isaac64 解密视频... Key: ${key}`)
|
|
||||||
const keyText = String(key).trim()
|
const keyText = String(key).trim()
|
||||||
let keystream: Buffer
|
let keystream: Buffer
|
||||||
|
|
||||||
@@ -344,9 +341,8 @@ class SnsService {
|
|||||||
keystream = await wasmService.getKeystream(keyText, 131072)
|
keystream = await wasmService.getKeystream(keyText, 131072)
|
||||||
} catch (wasmErr) {
|
} catch (wasmErr) {
|
||||||
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
|
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
|
||||||
console.warn(`[SnsService] WASM 解密不可用,回退 Isaac64: ${wasmErr}`)
|
|
||||||
const isaac = new Isaac64(keyText)
|
const isaac = new Isaac64(keyText)
|
||||||
keystream = isaac.generateKeystream(131072)
|
keystream = isaac.generateKeystreamBE(131072)
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptLen = Math.min(keystream.length, raw.length)
|
const decryptLen = Math.min(keystream.length, raw.length)
|
||||||
@@ -358,23 +354,16 @@ class SnsService {
|
|||||||
|
|
||||||
// 验证 MP4 签名 ('ftyp' at offset 4)
|
// 验证 MP4 签名 ('ftyp' at offset 4)
|
||||||
const ftyp = raw.subarray(4, 8).toString('ascii')
|
const ftyp = raw.subarray(4, 8).toString('ascii')
|
||||||
if (ftyp === 'ftyp') {
|
if (ftyp !== 'ftyp') {
|
||||||
console.log(`[SnsService] 视频解密成功: ${url}`)
|
// 可以在此处记录解密可能失败的标记,但不打印详细 hex
|
||||||
} else {
|
|
||||||
console.warn(`[SnsService] 视频解密可能失败: ${url}, 未找到 ftyp 签名: ${ftyp}`)
|
|
||||||
// 打印前 32 字节用于调试
|
|
||||||
console.warn(`[SnsService] Decrypted Header (first 32 bytes): ${raw.subarray(0, 32).toString('hex')}`)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[SnsService] 视频解密出错: ${err}`)
|
console.error(`[SnsService] 视频解密出错: ${err}`)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn(`[SnsService] 未提供 Key,跳过解密,直接保存`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入最终缓存 (覆盖)
|
// 写入最终缓存 (覆盖)
|
||||||
await writeFile(cachePath, raw)
|
await writeFile(cachePath, raw)
|
||||||
console.log(`[SnsService] 视频已保存到缓存: ${cachePath}`)
|
|
||||||
|
|
||||||
// 删除临时文件
|
// 删除临时文件
|
||||||
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
|
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
|
||||||
@@ -444,8 +433,24 @@ class SnsService {
|
|||||||
// 图片逻辑
|
// 图片逻辑
|
||||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||||
if (shouldDecrypt) {
|
if (shouldDecrypt) {
|
||||||
const decrypted = await wcdbService.decryptSnsImage(raw, String(key))
|
try {
|
||||||
decoded = Buffer.from(decrypted)
|
const keyStr = String(key).trim()
|
||||||
|
if (/^\d+$/.test(keyStr)) {
|
||||||
|
// 使用 WASM 版本的 Isaac64 解密图片
|
||||||
|
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
|
||||||
|
const wasmService = WasmService.getInstance()
|
||||||
|
const keystream = await wasmService.getKeystream(keyStr, raw.length)
|
||||||
|
|
||||||
|
const decrypted = Buffer.allocUnsafe(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
decrypted[i] = raw[i] ^ keystream[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded = decrypted
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsService] TS Decrypt Error:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入磁盘缓存
|
// 写入磁盘缓存
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export class WasmService {
|
|||||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
|
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
|
||||||
const jsPath = path.join(basePath, 'wasm_video_decode.js');
|
const jsPath = path.join(basePath, 'wasm_video_decode.js');
|
||||||
|
|
||||||
console.log('[WasmService] Loading WASM from:', wasmPath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||||
throw new Error(`WASM files not found at ${basePath}`);
|
throw new Error(`WASM files not found at ${basePath}`);
|
||||||
@@ -88,7 +87,6 @@ export class WasmService {
|
|||||||
// Define Module
|
// Define Module
|
||||||
mockGlobal.Module = {
|
mockGlobal.Module = {
|
||||||
onRuntimeInitialized: () => {
|
onRuntimeInitialized: () => {
|
||||||
console.log("[WasmService] WASM Runtime Initialized");
|
|
||||||
this.wasmLoaded = true;
|
this.wasmLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
@@ -133,10 +131,24 @@ export class WasmService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
|
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
|
||||||
|
// the global reverse() will cause a shift in alignment.
|
||||||
|
const alignSize = Math.ceil(size / 8) * 8;
|
||||||
|
const buffer = await this.getRawKeystream(key, alignSize);
|
||||||
|
|
||||||
|
// Reverse the entire aligned buffer
|
||||||
|
const reversed = new Uint8Array(buffer);
|
||||||
|
reversed.reverse();
|
||||||
|
|
||||||
|
// Return exactly the requested size from the beginning of the reversed stream.
|
||||||
|
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
|
||||||
|
return Buffer.from(reversed).subarray(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
|
||||||
if (!this.module || !this.module.WxIsaac64) {
|
if (!this.module || !this.module.WxIsaac64) {
|
||||||
// Fallback check for asm.WxIsaac64 logic if needed, but debug showed it on Module
|
|
||||||
if (this.module.asm && this.module.asm.WxIsaac64) {
|
if (this.module.asm && this.module.asm.WxIsaac64) {
|
||||||
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
|
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
|
||||||
}
|
}
|
||||||
@@ -149,26 +161,19 @@ export class WasmService {
|
|||||||
try {
|
try {
|
||||||
this.capturedKeystream = null;
|
this.capturedKeystream = null;
|
||||||
const isaac = new this.module.WxIsaac64(key);
|
const isaac = new this.module.WxIsaac64(key);
|
||||||
isaac.generate(size); // This triggers the global.wasm_isaac_generate callback
|
isaac.generate(size);
|
||||||
|
|
||||||
// Cleanup if possible? isaac.delete()?
|
|
||||||
// In worker code: p.decryptor.delete()
|
|
||||||
if (isaac.delete) {
|
if (isaac.delete) {
|
||||||
isaac.delete();
|
isaac.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.capturedKeystream) {
|
if (this.capturedKeystream) {
|
||||||
// The worker_release.js logic does:
|
return Buffer.from(this.capturedKeystream);
|
||||||
// p.decryptor_array.set(r.reverse())
|
|
||||||
// So the actual keystream is the REVERSE of what is passed to the callback.
|
|
||||||
const reversed = new Uint8Array(this.capturedKeystream);
|
|
||||||
reversed.reverse();
|
|
||||||
return Buffer.from(reversed);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
|
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WasmService] Error generating keystream:', error);
|
console.error('[WasmService] Error generating raw keystream:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class WcdbCore {
|
|||||||
private wcdbStopMonitorPipe: any = null
|
private wcdbStopMonitorPipe: any = null
|
||||||
|
|
||||||
private monitorPipeClient: any = null
|
private monitorPipeClient: any = null
|
||||||
private wcdbDecryptSnsImage: any = null
|
|
||||||
|
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
@@ -144,42 +144,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解密朋友圈图片
|
|
||||||
*/
|
|
||||||
async decryptSnsImage(encryptedData: Buffer, key: string): Promise<Buffer> {
|
|
||||||
if (!this.initialized) {
|
|
||||||
const initOk = await this.initialize()
|
|
||||||
if (!initOk) return encryptedData
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.wcdbDecryptSnsImage) return encryptedData
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.wcdbDecryptSnsImage) {
|
|
||||||
console.error('[WCDB] wcdbDecryptSnsImage func is null')
|
|
||||||
return encryptedData
|
|
||||||
}
|
|
||||||
|
|
||||||
const outPtr = [null as any]
|
|
||||||
// Koffi pass Buffer as char* pointer
|
|
||||||
const result = this.wcdbDecryptSnsImage(encryptedData, encryptedData.length, key, outPtr)
|
|
||||||
|
|
||||||
if (result === 0 && outPtr[0]) {
|
|
||||||
const hex = this.decodeJsonPtr(outPtr[0])
|
|
||||||
if (hex) {
|
|
||||||
return Buffer.from(hex, 'hex')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`[WCDB] Decrypt SNS image failed with code: ${result}`)
|
|
||||||
// 主动获取 DLL 内部日志以诊断问题
|
|
||||||
await this.printLogs(true)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解密图片失败:', e)
|
|
||||||
}
|
|
||||||
return encryptedData
|
|
||||||
}
|
|
||||||
|
|
||||||
stopMonitor(): void {
|
stopMonitor(): void {
|
||||||
if (this.monitorPipeClient) {
|
if (this.monitorPipeClient) {
|
||||||
@@ -602,12 +567,7 @@ export class WcdbCore {
|
|||||||
this.wcdbVerifyUser = null
|
this.wcdbVerifyUser = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_decrypt_sns_image(const char* encrypted_data, int32_t data_len, const char* key, char** out_hex)
|
|
||||||
try {
|
|
||||||
this.wcdbDecryptSnsImage = this.lib.func('int32 wcdb_decrypt_sns_image(const char* data, int32 len, const char* key, _Out_ void** outHex)')
|
|
||||||
} catch {
|
|
||||||
this.wcdbDecryptSnsImage = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
|
|||||||
@@ -431,12 +431,7 @@ export class WcdbService {
|
|||||||
return this.callWorker('verifyUser', { message, hwnd })
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解密朋友圈图片
|
|
||||||
*/
|
|
||||||
async decryptSnsImage(encryptedData: Buffer, key: string): Promise<Buffer> {
|
|
||||||
return this.callWorker<Buffer>('decryptSnsImage', { encryptedData, key })
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,7 @@ if (parentPort) {
|
|||||||
case 'verifyUser':
|
case 'verifyUser':
|
||||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
break
|
break
|
||||||
case 'decryptSnsImage':
|
|
||||||
result = await core.decryptSnsImage(payload.encryptedData, payload.key)
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.4",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.4",
|
"version": "2.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.4",
|
"version": "2.0.1",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
|||||||
@@ -482,11 +482,41 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exclude-footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.exclude-count {
|
.exclude-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.exclude-actions {
|
.exclude-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ function AnalyticsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleInvertSelection = () => {
|
||||||
|
setDraftExcluded((prev) => {
|
||||||
|
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
|
||||||
|
const inverted = new Set<string>()
|
||||||
|
for (const u of allUsernames) {
|
||||||
|
if (!prev.has(u)) inverted.add(u)
|
||||||
|
}
|
||||||
|
return inverted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleApplyExcluded = async () => {
|
const handleApplyExcluded = async () => {
|
||||||
const payload = Array.from(draftExcluded)
|
const payload = Array.from(draftExcluded)
|
||||||
setIsExcludeDialogOpen(false)
|
setIsExcludeDialogOpen(false)
|
||||||
@@ -493,7 +504,12 @@ function AnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="exclude-modal-footer">
|
<div className="exclude-modal-footer">
|
||||||
|
<div className="exclude-footer-left">
|
||||||
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||||
|
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="exclude-actions">
|
<div className="exclude-actions">
|
||||||
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function SettingsPage() {
|
|||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [weixinDllPath, setWeixinDllPath] = useState('')
|
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||||
@@ -250,7 +250,7 @@ function SettingsPage() {
|
|||||||
const savedPath = await configService.getDbPath()
|
const savedPath = await configService.getDbPath()
|
||||||
const savedWxid = await configService.getMyWxid()
|
const savedWxid = await configService.getMyWxid()
|
||||||
const savedCachePath = await configService.getCachePath()
|
const savedCachePath = await configService.getCachePath()
|
||||||
const savedWeixinDllPath = await configService.getWeixinDllPath()
|
|
||||||
const savedExportPath = await configService.getExportPath()
|
const savedExportPath = await configService.getExportPath()
|
||||||
const savedLogEnabled = await configService.getLogEnabled()
|
const savedLogEnabled = await configService.getLogEnabled()
|
||||||
const savedImageXorKey = await configService.getImageXorKey()
|
const savedImageXorKey = await configService.getImageXorKey()
|
||||||
@@ -279,7 +279,7 @@ function SettingsPage() {
|
|||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
if (savedWxid) setWxid(savedWxid)
|
if (savedWxid) setWxid(savedWxid)
|
||||||
if (savedCachePath) setCachePath(savedCachePath)
|
if (savedCachePath) setCachePath(savedCachePath)
|
||||||
if (savedWeixinDllPath) setWeixinDllPath(savedWeixinDllPath)
|
|
||||||
|
|
||||||
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
|
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
|
||||||
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
|
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
|
||||||
@@ -616,29 +616,7 @@ function SettingsPage() {
|
|||||||
await applyWxidSelection(selectedWxid)
|
await applyWxidSelection(selectedWxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectWeixinDllPath = async () => {
|
|
||||||
try {
|
|
||||||
const result = await dialog.openFile({
|
|
||||||
title: '选择 Weixin.dll 文件',
|
|
||||||
properties: ['openFile'],
|
|
||||||
filters: [{ name: 'DLL', extensions: ['dll'] }]
|
|
||||||
})
|
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
|
||||||
const selectedPath = result.filePaths[0]
|
|
||||||
setWeixinDllPath(selectedPath)
|
|
||||||
await configService.setWeixinDllPath(selectedPath)
|
|
||||||
showMessage('已选择 Weixin.dll 路径', true)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showMessage('选择 Weixin.dll 失败', false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetWeixinDllPath = async () => {
|
|
||||||
setWeixinDllPath('')
|
|
||||||
await configService.setWeixinDllPath('')
|
|
||||||
showMessage('已清空 Weixin.dll 路径', true)
|
|
||||||
}
|
|
||||||
const handleSelectCachePath = async () => {
|
const handleSelectCachePath = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||||
@@ -1332,28 +1310,7 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Weixin.dll 路径 <span className="optional">(可选)</span></label>
|
|
||||||
<span className="form-hint">用于朋友圈在线图片原生解密,优先使用这里配置的 DLL</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="例如: D:\weixindata\Weixin\Weixin.dll"
|
|
||||||
value={weixinDllPath}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
setWeixinDllPath(value)
|
|
||||||
scheduleConfigSave('weixinDllPath', () => configService.setWeixinDllPath(value))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="btn-row">
|
|
||||||
<button className="btn btn-secondary" onClick={handleSelectWeixinDllPath}>
|
|
||||||
<FolderOpen size={16} /> 浏览选择
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={handleResetWeixinDllPath}>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>账号 wxid</label>
|
<label>账号 wxid</label>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const CONFIG_KEYS = {
|
|||||||
LAST_SESSION: 'lastSession',
|
LAST_SESSION: 'lastSession',
|
||||||
WINDOW_BOUNDS: 'windowBounds',
|
WINDOW_BOUNDS: 'windowBounds',
|
||||||
CACHE_PATH: 'cachePath',
|
CACHE_PATH: 'cachePath',
|
||||||
WEIXIN_DLL_PATH: 'weixinDllPath',
|
|
||||||
EXPORT_PATH: 'exportPath',
|
EXPORT_PATH: 'exportPath',
|
||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
LOG_ENABLED: 'logEnabled',
|
LOG_ENABLED: 'logEnabled',
|
||||||
@@ -163,16 +163,7 @@ export async function setCachePath(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 获取 Weixin.dll 路径
|
|
||||||
export async function getWeixinDllPath(): Promise<string | null> {
|
|
||||||
const value = await config.get(CONFIG_KEYS.WEIXIN_DLL_PATH)
|
|
||||||
return value as string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置 Weixin.dll 路径
|
|
||||||
export async function setWeixinDllPath(path: string): Promise<void> {
|
|
||||||
await config.set(CONFIG_KEYS.WEIXIN_DLL_PATH, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取导出路径
|
// 获取导出路径
|
||||||
export async function getExportPath(): Promise<string | null> {
|
export async function getExportPath(): Promise<string | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user