diff --git a/electron/main.ts b/electron/main.ts index 6abaa29..51ac53f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -932,8 +932,10 @@ function registerIpcHandlers() { return snsService.debugResource(url) }) - ipcMain.handle('sns:proxyImage', async (_, url: string) => { - return snsService.proxyImage(url) + ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => { + const url = typeof payload === 'string' ? payload : payload?.url + const key = typeof payload === 'string' ? undefined : payload?.key + return snsService.proxyImage(url, key) }) // 私聊克隆 diff --git a/electron/preload.ts b/electron/preload.ts index fc60323..41106b5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -271,7 +271,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), - proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url) + proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload) }, // Llama AI diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 05f0f7f..7f29f23 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1,6 +1,8 @@ -import { wcdbService } from './wcdbService' +import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' +import { existsSync } from 'fs' +import { basename, join } from 'path' export interface SnsLivePhoto { url: string @@ -32,24 +34,57 @@ export interface SnsPost { media: SnsMedia[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] - rawXml?: string // 原始 XML 数据 + rawXml?: string } +const WEIXIN_DLL_OFFSET = 0x2674280n + const fixSnsUrl = (url: string, token?: string) => { - if (!url) return url; + if (!url) return url - // 1. 统一使用 https - // 2. 将 /150 (缩略图) 强制改为 /0 (原图) - let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1'); + let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1') + if (!token || fixedUrl.includes('token=')) return fixedUrl - if (!token || fixedUrl.includes('token=')) return fixedUrl; + const connector = fixedUrl.includes('?') ? '&' : '?' + return `${fixedUrl}${connector}token=${token}&idx=1` +} - const connector = fixedUrl.includes('?') ? '&' : '?'; - return `${fixedUrl}${connector}token=${token}&idx=1`; -}; +const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { + if (!buf || buf.length < 4) return fallback + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' + + if ( + buf.length >= 8 && + buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && + buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a + ) return 'image/png' + + if (buf.length >= 6) { + const sig = buf.subarray(0, 6).toString('ascii') + if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif' + } + + if ( + buf.length >= 12 && + buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && + buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 + ) return 'image/webp' + + if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp' + return fallback +} class SnsService { private contactCache: ContactCacheService + private imageCache = new Map() + + private nativeDecryptInit = false + private nativeDecryptReady = false + private nativeDecryptError = '' + private nativeDecryptDllPath = '' + private nativeKoffi: any = null + private nativeWeixinLib: any = null + private nativeDecryptFn: any = null constructor() { const config = new ConfigService() @@ -57,57 +92,29 @@ class SnsService { } async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - - const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) - - if (result.success && result.timeline) { - const enrichedTimeline = result.timeline.map((post: any, index: number) => { + const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) - - // 修复媒体 URL - const fixedMedia = post.media.map((m: any, mIdx: number) => { - const base = { - url: fixSnsUrl(m.url, m.token), - thumb: fixSnsUrl(m.thumb, m.token), - md5: m.md5, - token: m.token, - key: m.key, - encIdx: m.encIdx || m.enc_idx, // 兼容不同命名 - livePhoto: m.livePhoto ? { + const fixedMedia = (post.media || []).map((m: any) => ({ + url: fixSnsUrl(m.url, m.token), + thumb: fixSnsUrl(m.thumb, m.token), + md5: m.md5, + token: m.token, + key: m.key, + encIdx: m.encIdx || m.enc_idx, + livePhoto: m.livePhoto + ? { ...m.livePhoto, url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token), thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token), token: m.livePhoto.token, - key: m.livePhoto.key - } : undefined - } - - // [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发 - if (!base.key) { - base.key = 'mock_key_for_dev' - if (!base.token) { - base.token = 'mock_token_for_dev' - base.url = fixSnsUrl(base.url, base.token) - base.thumb = fixSnsUrl(base.thumb, base.token) + key: m.livePhoto.key, + encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx } - base.encIdx = '1' - - // 强制给第一个帖子的第一张图加 LivePhoto 模拟 - if (index === 0 && mIdx === 0 && !base.livePhoto) { - base.livePhoto = { - url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'), - thumb: base.thumb, - token: 'mock_live_token', - key: 'mock_live_key' - } - } - } - - return base - }) + : undefined + })) return { ...post, @@ -116,20 +123,15 @@ class SnsService { media: fixedMedia } }) - - return { ...result, timeline: enrichedTimeline } } - return result } + async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { return new Promise((resolve) => { try { - const { app, net } = require('electron') - // Remove mocking 'require' if it causes issues, but here we need 'net' or 'https' - // implementing with 'https' for reliability if 'net' is main-process only special const https = require('https') const urlObj = new URL(url) @@ -138,13 +140,12 @@ class SnsService { path: urlObj.pathname + urlObj.search, method: 'GET', headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", - "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9", - "Referer": "https://servicewechat.com/", - "Connection": "keep-alive", - "Range": "bytes=0-10" // Keep our range check + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Connection': 'keep-alive', + 'Range': 'bytes=0-10' } } @@ -154,17 +155,15 @@ class SnsService { status: res.statusCode, headers: { 'x-enc': res.headers['x-enc'], + 'x-time': res.headers['x-time'], 'content-length': res.headers['content-length'], 'content-type': res.headers['content-type'] } }) - req.destroy() // We only need headers - }) - - req.on('error', (e: any) => { - resolve({ success: false, error: e.message }) + req.destroy() }) + req.on('error', (e: any) => resolve({ success: false, error: e.message })) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) @@ -172,12 +171,123 @@ class SnsService { }) } - private imageCache = new Map() + private parseSnsKey(key?: string | number): bigint | null { + if (key === undefined || key === null) return null + if (typeof key === 'number') return BigInt(Math.trunc(key)) + const raw = String(key).trim() + if (!raw) return null + try { + return BigInt(raw) + } catch { + return null + } + } - async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> { - // Check cache - if (this.imageCache.has(url)) { - return { success: true, dataUrl: this.imageCache.get(url) } + private resolveWeixinDllPath(): string | null { + const candidates: string[] = [] + if (process.env.WEFLOW_WEIXIN_DLL) candidates.push(process.env.WEFLOW_WEIXIN_DLL) + + const weixinExe = process.env.WEFLOW_WEIXIN_EXE + if (weixinExe) { + const dir = weixinExe.replace(/\\Weixin\.exe$/i, '') + if (dir && dir !== weixinExe) candidates.push(join(dir, 'Weixin.dll')) + } + + const programFiles = process.env.ProgramFiles || 'C:\\Program Files' + const localAppData = process.env.LOCALAPPDATA || '' + candidates.push( + join(programFiles, 'Tencent', 'Weixin', 'Weixin.dll'), + 'D:\\weixindata\\Weixin\\Weixin.dll', + 'C:\\Users\\16586\\Desktop\\sns\\Weixin.dll' + ) + if (localAppData) candidates.push(join(localAppData, 'Tencent', 'xwechat', 'Weixin.dll')) + + for (const p of candidates) { + if (p && existsSync(p)) return p + } + return null + } + + private ensureNativeDecryptor(): boolean { + if (this.nativeDecryptInit) return this.nativeDecryptReady + this.nativeDecryptInit = true + + try { + const dllPath = this.resolveWeixinDllPath() + if (!dllPath) { + this.nativeDecryptError = 'Weixin.dll not found, set WEFLOW_WEIXIN_DLL if needed' + return false + } + + const koffi = require('koffi') + const kernel32 = koffi.load('kernel32.dll') + const getModuleHandleW = kernel32.func('void* __stdcall GetModuleHandleW(str16 lpModuleName)') + + const weixinLib = koffi.load(dllPath) + + let modulePtr = getModuleHandleW('Weixin.dll') + if (!modulePtr) modulePtr = getModuleHandleW(basename(dllPath)) + if (!modulePtr) { + this.nativeDecryptError = `GetModuleHandleW 失败: ${dllPath}` + return false + } + + const base = koffi.address(modulePtr) as bigint + const decryptAddr = base + WEIXIN_DLL_OFFSET + + // Koffi requires an external pointer object (not raw Number/BigInt). + // Build a temporary uint64 box, decode it to void*, then decode function pointer. + const addrBox = new BigUint64Array(1) + addrBox[0] = decryptAddr + const decryptPtr = koffi.decode(addrBox, 'void *') + if (!decryptPtr) { + this.nativeDecryptError = `Decode function pointer failed: ${decryptAddr.toString(16)}` + return false + } + + const decryptProto = koffi.proto('uint64 __fastcall SnsImageDecrypt(void* src, uint64 len, void* dst, uint64 key)') + this.nativeDecryptFn = koffi.decode(decryptPtr, decryptProto) + this.nativeKoffi = koffi + this.nativeWeixinLib = weixinLib + this.nativeDecryptReady = true + this.nativeDecryptDllPath = dllPath + console.info('[SNS] Native decrypt enabled:', this.nativeDecryptDllPath) + return true + } catch (e: any) { + this.nativeDecryptError = e?.message || String(e) + this.nativeDecryptReady = false + console.warn('[SNS] Native decrypt init failed:', this.nativeDecryptError) + return false + } + } + + private decryptSnsEncryptedImage(data: Buffer, key: string | number): Buffer { + const parsedKey = this.parseSnsKey(key) + if (!parsedKey) return data + if (!this.ensureNativeDecryptor()) return data + + const out = Buffer.allocUnsafe(data.length) + try { + this.nativeDecryptFn( + data, + BigInt(data.length), + out, + parsedKey + ) + return out + } catch (e: any) { + this.nativeDecryptError = e?.message || String(e) + console.warn('[SNS] Native decrypt call failed:', this.nativeDecryptError) + return data + } + } + + async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; error?: string }> { + if (!url) return { success: false, error: 'url 不能为空' } + const cacheKey = `${url}|${key ?? ''}` + + if (this.imageCache.has(cacheKey)) { + return { success: true, dataUrl: this.imageCache.get(cacheKey) } } return new Promise((resolve) => { @@ -191,12 +301,11 @@ class SnsService { path: urlObj.pathname + urlObj.search, method: 'GET', headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", - "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9", - "Referer": "https://servicewechat.com/", - "Connection": "keep-alive" + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Connection': 'keep-alive' } } @@ -209,37 +318,28 @@ class SnsService { const chunks: Buffer[] = [] let stream = res - // Handle gzip compression const encoding = res.headers['content-encoding'] - if (encoding === 'gzip') { - stream = res.pipe(zlib.createGunzip()) - } else if (encoding === 'deflate') { - stream = res.pipe(zlib.createInflate()) - } else if (encoding === 'br') { - stream = res.pipe(zlib.createBrotliDecompress()) - } + if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip()) + else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate()) + else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress()) stream.on('data', (chunk: Buffer) => chunks.push(chunk)) stream.on('end', () => { - const buffer = Buffer.concat(chunks) - const contentType = res.headers['content-type'] || 'image/jpeg' - const base64 = buffer.toString('base64') - const dataUrl = `data:${contentType};base64,${base64}` + const raw = Buffer.concat(chunks) + const xEnc = String(res.headers['x-enc'] || '').trim() + const shouldDecrypt = xEnc === '1' && key !== undefined && key !== null && String(key).trim().length > 0 + const decoded = shouldDecrypt ? this.decryptSnsEncryptedImage(raw, key as string | number) : raw - // Cache - this.imageCache.set(url, dataUrl) + const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string) + const dataUrl = `data:${contentType};base64,${decoded.toString('base64')}` + this.imageCache.set(cacheKey, dataUrl) resolve({ success: true, dataUrl }) }) - stream.on('error', (e: any) => { - resolve({ success: false, error: e.message }) - }) - }) - - req.on('error', (e: any) => { - resolve({ success: false, error: e.message }) + stream.on('error', (e: any) => resolve({ success: false, error: e.message })) }) + req.on('error', (e: any) => resolve({ success: false, error: e.message })) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) @@ -249,3 +349,6 @@ class SnsService { } export const snsService = new SnsService() + + + diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 6140902..d3ee527 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -34,31 +34,51 @@ interface SnsPost { rawXml?: string // 原始 XML 数据 } -const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => { - const [error, setError] = useState(false); - const { url, thumb, livePhoto } = media; - const isLive = !!livePhoto; - const targetUrl = thumb || url; +const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string) => void }) => { + const [error, setError] = useState(false) + const [resolvedSrc, setResolvedSrc] = useState('') + const { url, thumb, livePhoto } = media + const isLive = !!livePhoto + const targetUrl = thumb || url - const handleDownload = (e: React.MouseEvent) => { - e.stopPropagation(); + useEffect(() => { + let cancelled = false + setError(false) + setResolvedSrc('') - let downloadUrl = url; - let downloadKey = media.key || ''; - - if (isLive && media.livePhoto) { - downloadUrl = media.livePhoto.url; - downloadKey = media.livePhoto.key || ''; + const run = async () => { + try { + const result = await window.electronAPI.sns.proxyImage({ + url: targetUrl, + key: media.key + }) + if (cancelled) return + if (result.success && result.dataUrl) { + setResolvedSrc(result.dataUrl) + } else { + setResolvedSrc(targetUrl) + } + } catch { + if (!cancelled) setResolvedSrc(targetUrl) + } } - // TODO: 调用后端下载服务 - // window.electronAPI.sns.download(downloadUrl, downloadKey); - }; + run() + return () => { cancelled = true } + }, [targetUrl, media.key]) + + const handleDownload = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: call backend download service + } + + const displaySrc = resolvedSrc || targetUrl + const previewSrc = resolvedSrc || url || targetUrl return ( -
+
onPreview(previewSrc)}> void })
)} -
- ); -}; + ) +} interface Contact { username: string @@ -471,7 +491,7 @@ export default function SnsPage() { ) : post.media.length > 0 && (
{post.media.map((m, idx) => ( - setPreviewImage(m.url)} /> + setPreviewImage(src)} /> ))}
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d71f28e..5bc67cc 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -477,7 +477,7 @@ export interface ElectronAPI { error?: string }> debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> - proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }> + proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }> } llama: { loadModel: (modelPath: string) => Promise