Applies to:
package.jsonv5.0.0 · Electron 43 · React 19 · TypeScript 6 · Vite 8WeFlow is a fully local tool for real-time viewing, analysis, and export of WeChat (4.0+) chat history. Nothing goes through a server—all data reading, decryption, and analysis happen on the user's own machine. This article sets out to take the whole thing apart, from the overall architecture and process model to the data pipeline, key management, export pipeline, AI capabilities, and build tooling.
Table of Contents
- 1. Project Scope and Overall Architecture
- 2. Build System: Vite Multi-Entry + Electron
- 3. Process and Thread Model
- 4. Data Layer: The WCDB Native Data Service
- 5. Key Management: From Database Key to Image Key
- 6. The Media Decryption Pipeline
- 7. The Real-Time Pipeline: Database Watch → Push → Notification
- 8. The Export Subsystem
- 9. The AI Capability Layer
- 10. The HTTP API and External Integration
- 11. Renderer (React) Architecture
- 12. Visualization and Desktop Effects
- 13. Packaging, Distribution, and Updates
- 14. Summary of Key Design Trade-offs
1. Project Scope and Overall Architecture
1.1 Top-Level Module Diagram
1. Directory Structure at a Glance
WeFlow/
├── electron/ # Main process
│ ├── main.ts # Entry: window/tray/IPC registration/auto-update (4795 lines)
│ ├── preload.ts # contextBridge API (686 lines)
│ ├── wcdbWorker.ts # Database Worker (message protocol dispatch)
│ ├── exportWorker.ts # Export Worker
│ ├── annualReportWorker.ts / dualReportWorker.ts # Report Worker
│ ├── transcribeWorker.ts # Speech recognition Worker
│ ├── imageDecryptWorker.ts / imageSearchWorker.ts # Image pipeline Worker
│ ├── apiMessageWorker.ts # HTTP API message-mapping Worker
│ ├── windows/ # Notification glass window and other child windows
│ ├── services/ # ~50 business services (chat/sns/export/key/...)
│ │ └── export/ # Export orchestration + 8 Formatters
│ └── utils/ # LRUCache, pathUtils
├── src/ # Renderer process (React)
│ ├── pages/ # 30+ routed pages (the Chat one is a 510k-line file)
│ ├── stores/ # Zustand store
│ ├── services/ # ipc.ts / config.ts / cloudControl.ts
│ ├── components/ # Shared components + LiquidGlass glass effects
│ └── types/ # Domain models (models.ts etc.)
├── resources/ # Native binaries: wcdb / wedecrypt / key / welive
│ ├── wcdb/{win32,macos,linux}/$arch # WCDB C API data service
│ ├── wedecrypt/… # .dat image decryption native addon
│ ├── key/… # Key-extraction dll (with mac source)
│ └── welive/… # Offline exporter implemented in Rust
├── shared/groupSummaryPrompt.json # Prompt shared between frontend and backend
├── scripts/ # after-pack / prepare-electron-runtime
└── vite.config.ts # 10 Electron build entries
2. The Build System: Vite Multi-Entry + Electron
WeFlow uses vite-plugin-electron to pull the main process, preload, and all 8 workers into a single Vite build graph, each producing its own bundle:
// vite.config.ts (excerpt)
export default defineConfig({
plugins: [
react(),
electron([
{ entry: 'electron/main.ts', onstart: handleElectronOnStart, ... },
{ entry: 'electron/wcdbWorker.ts', output: { entryFileNames: 'wcdbWorker.js' } },
{ entry: 'electron/exportWorker.ts',
plugins: [exportWorkerElectronShimPlugin()], ... },
{ entry: 'electron/preload.ts', ... },
// annualReportWorker / dualReportWorker / transcribeWorker /
// imageDecryptWorker / imageSearchWorker / apiMessageWorker ...
])
],
resolve: { alias: { '@': resolve(__dirname, 'src') } }
})
Three key engineering moves:
Externalizing native modules.
koffi(FFI),better-sqlite3,silk-wasm, and [@hicccc77/electron-liquid-glass] are all declared external and resolved at runtime fromapp.asar.unpackedviaasarUnpack, sidestepping ABI changes after Electron packaging.An Electron shim inside the Worker (a virtual module). The export Worker reuses main-process service code (such as
ConfigService), but that code doesimport 'electron'. A custom plugin swaps it out at build time:
// vite.config.ts — exportWorkerElectronShimPlugin
const next = code
.replace(/from\s+(['"])electron\1/g, `from '${virtualId}'`) // import … from 'electron'
.replace(/require\s*\(\s*(['"])electron\1\s*\)/g, `require('${virtualId}')`)
The virtual module virtual:weflow-export-worker-electron returns a fake, pure-Node app (getPath driven by the WEFLOW_USER_DATA_PATH env var, safeStorage passed straight through) and replaces things like BrowserWindow.getAllWindows with no-ops:
// The generated virtual module (illustrative)
export const app = {
isPackaged: Boolean(process.resourcesPath && ...),
getPath: (name) => name === 'userData'
? workerUserDataPath() || join(appDataPath(), 'WeFlow')
: ...,
on: () => app, // App lifecycle events like will-quit don't exist in a Worker
}
This lets the same Service code run in both the main process and the Worker, with no need to maintain a separate "pure Node config" copy just for Workers.
- postinstall runtime prep.
scripts/prepare-electron-runtime.cjsrunselectron-builder install-app-depsafter install and sets up the Webview runtime;after-pack.cjsrewriteslibwcdb_api.dylib's link toWCDB.frameworkon macOS usingotool+install_name_tool(@rpath→@loader_path), so unsigned AppImage/DMG builds still load.
3. Process and Thread Model
3.1 Process Topology
Why so many Worker threads?
- Big queries like
chat:getMessages, report aggregation, and export (which can produce thousands of files in seconds) are all CPU- and IO-heavy; - if the main process blocks, windows freeze (the Electron main process is a single-threaded event loop);
- Worker threads are lighter than spinning up
utilityProcessprocesses, and Buffers passed viapostMessageare zero-copy.
3.2 The Worker RPC Protocol
Every Worker speaks the same four-field message protocol: { id, type, payload } → { id, result | error }. wcdbService.ts is a typical example:
// electron/services/wcdbService.ts — generic RPC client
private callWorker<T>(type: string, payload: any = {}): Promise<T> {
if (!this.worker) this.initWorker()
if (!this.worker) return Promise.reject(new Error('WCDB Worker unavailable'))
return new Promise((resolve, reject) => {
const id = ++this.messageId
this.pending.set(id, { resolve, reject })
this.worker!.postMessage({ id, type, payload })
})
}
// The Worker dispatches by type
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'startMonitor':
core.setMonitor((type, json) => parentPort!.postMessage({
id: -1, type: 'monitor', payload: { type, json } }))
break
Three fault-tolerance design choices:
- exit ≠ 0: when a Worker dies unexpectedly, reject every pending Promise and surface a human-readable hint (e.g., a missing VC++ Redistributable);
id = -1is reserved for server-initiated push messages (such as database watch callbacks), distinguishing them from request-response messages;- Worker crash self-healing: the next
callWorkertriggersinitWorker(), which automatically rebuilds the Worker and replays thesetPaths / setLogEnabled / setMonitorstate.
3.3 The IPC Bridge (preload)
preload.ts exposes roughly 35 namespaces via contextBridge.exposeInMainWorld('electronAPI', …) (config / chat / sns / insight / export / backup / analytics / http / image / window / auth / …), matching 195 ipcMain.handle registrations on the main-process side (chat 45, sns 18, insight 13, window 12, group 10…). The typical pattern:
// Main process: register
ipcMain.handle('chat:getMessages',
async (_, sessionId, offset, limit, startTime, endTime, ascending) =>
chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending))
// preload: forward
chat: {
getMessages: (sessionId, offset, limit, startTime, endTime, ascending, cursor) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending, cursor)
}
// Renderer: wrap it in one more thin JS facade
export const chat = { getMessages: (...) => window.electronAPI.chat.getMessages(...) }
Every renderer window runs with contextIsolation: true, nodeIntegration: false; webSecurity: false is used only for the main window that needs local video playback.
4. The Data Layer: The WCDB Native Data Service
4.1 The Key Idea: No Node-Side SQL Engine
WeFlow doesn't implement SQLite decryption itself. Instead it loads the WCDB C API shared library from the same lineage as the WeChat ecosystem (resources/wcdb/<platform>/<arch>/libwcdb_api) and calls it from Node via koffi (FFI). That way, SQL key decryption, SQLCipher variants, and schema differences are all handled by the same WCDB library WeChat itself uses.
// electron/services/wcdbCore.ts — FFI bindings (excerpt)
this.wcdbInit = this.lib.func('int32 wcdb_init()')
this.wcdbOpenAccount = this.lib.func('... wcdb_open_account(...)')
this.wcdbGetSessions = this.lib.func('... wcdb_get_sessions(...)') // session list
this.wcdbGetMessages = this.lib.func('... wcdb_get_messages(...)') // message pagination
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()') // file watching
this.wcdbGetVoiceData = this.lib.func(
'int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func(...) // anti-revoke (SQL trigger)
4.2 The Session/Message Domain (chatService.ts ≈ 12,800 lines)
Key mechanisms:
Cursor pagination:
openMessageCursor → fetchMessageBatch → closeMessageCursor. Paging up and paging down each hold their own cursor, reclaimed bytrimMessageCursorStateswhen you switch sessions; a 15-second forced-reopen cooldown guards against long-lived cursors going stale.Cross-database dedup: WeChat shards by year and by session (
message_*.db), solocalId / serverIdaren't guaranteed to be globally unique. The renderer'schatStorebuilds multiple alias keys for each message:
// src/stores/chatStore.ts — the key dedup logic
function buildMessageAliasKeys(message: Message): string[] {
const sourceScope = String(message._db_path || '').trim() // source shard
const keys = [buildPrimaryMessageKey(message, sourceScope)]
if (localId > 0) {
if (sourceScope) keys.push(`lid:${sourceScope}:${localId}`) // local_id can repeat across message_*.db
else keys.push(`lid_fallback:${localId}:${createTime}:${sender}:…`) // conservative combo to avoid over-deduping
}
if (serverId > 0) {
if (sourceScope) keys.push(`sid:${sourceScope}:${serverId}`)
else keys.push(`sid_fallback:${serverId}:${...}`)
}
return keys
}
- Multi-level in-memory caches:
displayNameCache(TTL 10 min, cap 20000),avatarUrlCache,hardlinkCache(resolving messages → physical image paths via hardlinks), andmediaStreamPageCache(30s TTL plus request-coalescinginflightdedup) all live insideWcdbCore.
5. Key Management: From Database Key to Image Key
WeFlow has to peel back three layers of encryption: database SQLCipher, sticker/image .dat, and video ISAAC-64.
5.1 The Windows Database Key: Hooking the WeChat Process
KeyService (Windows) loads resources/key/win32/<arch>/wx_key.dll (an unobfuscated injection DLL) through koffi. The steps:
A key implementation detail—waiting for the WeChat main UI to be ready: EnumWindows locates the "微信 / WeChat" window, and enumerating its child windows serves as the final stability check (so the QR-code login page isn't grabbed by mistake):
// electron/services/keyService.ts — window-ready heuristics
private hasReadyComponents(children) {
const readyTexts = ['聊天', '登录', '账号']
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', ...]
// any heuristic—title/class-name hit count, child-window count ≥14, distinct class names ≥3, etc.—marks it as "logged in"
}
and login-state detection: spotting text like "scan the QR code / please confirm on your phone" aborts the attempt and prompts the user to log in first. If the process or key isn't found within a 60s timeout, it returns a failure plus a log.
macOS goes through keyServiceMac.ts (lldb attaches to the process to read memory plus a board-level candidate search), while Linux uses keyServiceLinux.ts (@vscode/sudo-prompt escalates privileges to grep the WeChat process memory for key signatures).
5.2 The Image Key: Reverse-Engineering via deriveImageKeys
On WeChat 4.0, the image key isn't stored independently—it's deterministically derived from "code × wxid" and verified against real ciphertext:
// electron/services/keyService.ts
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF // XOR key = low 8 bits of code
const md5Full = crypto.createHash('md5')
.update(code.toString() + cleanedWxid)
.digest('hex')
const aesKey = md5Full.substring(0, 16) // AES-128 key = first 16 chars of MD5
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
const decipher = crypto.createDecipheriv('aes-128-ecb',
Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
// decrypt 16 known ciphertext bytes; success with valid plaintext → verification passes
}
The derivation is paired with two sources:
- Cached approach (
autoGetImageKey): WeChat storescodein a template file in the account directory; after readingcode, it enumerates candidate wxid combinations, derives, and verifies against ciphertext; - Memory scan (
autoGetImageKeyByMemoryScan): reads a "known original image ↔ ciphertext" screenshot pair and brute-forces the XOR & AES values.
The derived { xorKey, aesKey } goes into ConfigService, and users can override it manually on the settings page.
5.3 The Video Key: ISAAC-64 + WASM Reproduction
Videos are stream-encrypted with a keystream generated by the ISAAC-64 PRNG (WxIsaac64). WeFlow reproduces it two ways:
- A pure-TS implementation in
electron/services/isaac64.ts(BigInt bit operations); - WeChat's own WASM module (
electron/assets/wasm/wasm_video_decode.{wasm,js}), executed in an isolatedvm.createContext, hooking its Emscripten callback to capture the raw keystream:
// electron/services/wasmService.ts
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size)
this.capturedKeystream = new Uint8Array(buffer) // copy the WASM linear memory
}
public async getKeystream(key: string, size = 131072): Promise<Buffer> {
const alignSize = Math.ceil(size / 8) * 8 // ISAAC-64 works in 8-byte blocks, so it must be aligned
const buffer = await this.getRawKeystream(key, alignSize)
const reversed = new Uint8Array(buffer)
reversed.reverse() // WeChat's implementation reads the stream in reverse
return Buffer.from(reversed).subarray(0, size)
}
The reverse alignment is an implicit convention uncovered through reverse engineering: ISAAC-64's output order is the opposite of the order WeChat's encryption writer writes to disk, so only 8-byte alignment plus a full-stream reversal yields a byte stream that matches the original ciphertext.
6. The Media Decryption Pipeline
6.1 Images (.dat)
.dat files come in three arrangements: single-byte XOR, AES-128-ECB (16-byte key), and a mix of XOR and AES (the wxgf format). WeFlow uses the native addon wedecrypt (decryptDatNative(inputPath, xorKey, aesKey)) to do read → decrypt → detect the real format → return Buffer + extension in a single call; the Node side only handles path resolution and caching:
// electron/services/nativeImageDecrypt.ts
function addonCandidates() { // try multiple roots by platform/arch/asar-unpack location
roots = [cwd/resources/wedecrypt/$plat/$arch, process.resourcesPath/…]
}
export function decryptDatViaNative(
inputPath: string, xorKey: number, aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
const addon = loadAddon()
...
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
...
}
The main process performs this call in a dedicated decryptWorker (imageDecryptWorker.ts) so the synchronous FFI doesn't stall the event loop, falling back to a synchronous main-process path on failure. The imageDecryptService above it (≈ 2700 lines) maintains:
resolvedCache(messageKey → local decrypted file path, 12k entries), paired withimagePreloadServiceto batch-preheat while scrolling (queue priorities: high / normal / low);datNameScanMissAt: a negative cache with a short TTL that stops the same.datname from triggering repeated full-disk scans;- a dedup in-flight map: the same
sessionId+imageMd5+datNameis decrypted only once, and the second waiter just reuses the Promise.
6.2 Video (Sharding + Keystream Derivation)
WeChat writes video to disk in blocks keyed by "stream number + shard", and the video file's md5 is also stored in the database. videoService.ts (25k bytes) lays it out: the client requests video metadata → finds all shards for that file in video*.db → decrypts them in order with ISAAC-64 → stitches them with ffmpeg (ffmpeg-static) → returns a local mp4. Live Photos are represented as a .jpg + .mov pair, played back on the frontend with LivePhotoIcon and two synchronized streams.
6.3 Voice (silk-wasm + Local ASR)
- Raw voice bytes are fetched directly with the WCDB C API
wcdb_get_voice_data(batched version:wcdb_get_voice_data_batch); - silk-wasm decodes silk → PCM and writes it into a WAV Buffer;
transcribeWorkerloads the SenseVoice ONNX model (sherpa-onnx-node), downloading from ModelScope by default:
// electron/services/voiceTranscribeService.ts
const SENSEVOICE_MODEL = {
model: 'model.int8.onnx',
tokens: 'tokens.txt',
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
...
}
- Inside the Worker, rich-text post-processing runs: SenseVoice's output tags (
<|HAPPY|>,<|SAD|>,<|BGM|>,<|Laughter|>…) are mapped to emoji, while technical tags (<|itn|>,<|zh|>…) are stripped:
// electron/transcribeWorker.ts
const RICH_TAG_MAP = {
'<|HAPPY|>': '😊', '<|SAD|>': '😔', '<|BGM|>': '🎵', '<|Laughter|>': '😂', ...
}
function richTranscribePostProcess(text: string): string {
let processed = text
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
processed = processed.replace(new RegExp(tag.replace(/[|<>]/g, '\\$&'), 'gi'), replacement)
}
for (const tag of TECH_TAGS) { processed = processed.replace(..., '') }
return processed.replace(/\s+/g, ' ').trim()
}
7. The Real-Time Pipeline: Database Watch → Push → Notification
This is the core difference between WeFlow and a static "screenshot-and-export tool"—it's a near-real-time application.
Implementation highlights (wcdbCore.ts):
- The producer lives inside the C++ data service, not Node's
fs.watch: the main process connects to a single pipe (\.\pipe\weflow_monitor_<pid>), avoiding duplicate watchers across multiple windows and processes; - Auto-reconnect: when the socket
closes inconnectMonitorPipe,scheduleReconnectretries in a loop; - Normalized chunking: on macOS the delimiter may be
\0or adjacent JSON (} {), so it normalizes and splits lines uniformly:
const normalizedChunk = rawChunk
.replace(/\u0000/g, '\n')
.replace(/}\s*{/g, '}\n{')
buffer += normalizedChunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
- messagePushService (55k bytes): an incremental engine maintains
sessionBaseline(lastTimestamp+unreadCount) and two Maps (recentMessageKeysdedup, TTL 10 min); a 350ms debounce coalesces message bursts; revoke detection supports a "rescan of raw tokens from the last 150 seconds", restoring revoked messages and emitting amessage.revokeevent—together with the SQL trigger (wcdbInstallMessageAntiRevokeTrigger), this delivers message anti-revoke.
8. The Export Subsystem
Export is the project's other big chunk (roughly 30% of the backend code). Architecturally it's a classic orchestrator + strategy pattern, but pushed out to run on Worker threads.
8.1 Structure
ExportOrchestrator is a thin strategy-dispatch layer: all eight exportSessionToXxx methods delegate to the matching Formatter.export(...):
// electron/services/export/core/ExportOrchestrator.ts
export class ExportOrchestrator {
constructor(public context: ExportContext) {}
async exportSessionToChatLab(sessionId, outputPath, options, onProgress, control) {
const formatter = new ChatLabFormatter(this.context)
return formatter.export(sessionId, outputPath, options, onProgress, control)
}
async exportSessionToExcel(...) { return new ExcelFormatter(this.context).export(...) }
async exportSessionToHtml(...) { return new HtmlFormatter(this.context).export(...) }
// Markdown / Json / Sql / Txt / WeCloneCsv follow the same shape
}
8.2 Worker-Level Progress and Pause Protocol
exportWorker.ts uses batch buffering to cap the postMessage rate (so hundreds of thousands of tiny messages don't overwhelm IPC):
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
const CREATED_PATH_BATCH_LIMIT = 256
const PROGRESS_POST_INTERVAL_MS = 180
function queueCreatedFile(filePath: string) {
queuedCreatedFiles.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) flush()
else scheduleCreatedPathFlush()
}
The parent side likewise supports pause / resume / cancel (exportTaskControlService) by posting a control message to the Worker; the Worker checks controlState.stopRequested at each message-loop boundary.
8.3 Two Export Engines Side by Side
Besides the built-in TypeScript engine (ExportOrchestrator), the whole export can also be outsourced to the Rust-side welive executable (resources/welive/<platform>/<arch>/welive[.exe]), reporting progress through a child-process JSON event stream:
// electron/services/weliveBridge.ts — the welive child-process JSON event protocol
export type WeliveExportEvent =
| { type: 'ready'; total?: number; output_dir?: string }
| { type: 'progress'; phase?: string; current?: number; total?: number; ... }
| { type: 'created_file'; path?: string; session_id?: string }
| { type: 'session_error'; session_id?: string; error?: string }
| { type: 'result'; success?: boolean; success_count?: number; ... }
The first line of exportWorker.ts is runWeliveExport(...)—the engine is chosen by task config. It's a smart move: the Rust binary doesn't depend on the Node ABI and can read from disk and decrypt in native parallel, while the TS engine is more flexible and lets you customize formatters.
8.4 Copyright/Privacy Support — Backup and Automation
backupService.ts: snapshots and packages the WeChat message database (weflow-db-snapshots), writing each table to a.wfsnap, assembled into a zip, so upper layers can back up/restore;- Export automation (scheduled tasks): the renderer's
useAutomation.tsruns timed exports on a first-day-of-month 0:00 / every-N-days / 30s scheduler, with config inexportAutomationTaskMap, so exports keep running even when the export page isn't open (special mount logic inApp.tsx).
9. The AI Capability Layer
WeFlow's AI capabilities are fully locally orchestrated, with a bring-your-own LLM API (no built-in OpenAI key). The core is a set of services:
| Service | What it does |
|---|---|
insightService.ts | A "My Footprints" retrospective and AI-insight pushes. Trigger frequency, cooldowns, and list filtering are decided locally; after pulling real chat context (user authorization required) it assembles a prompt and calls a single AI model |
groupSummaryService.ts | Group chat profile summaries. The system prompt lives in shared/groupSummaryPrompt.json, shared between frontend and backend, and users can override it |
annualReportService.ts / dualReportService.ts | Annual report / dual-person report. Aggregate statistics are computed by the native wcdbGetAnnualReportStats / wcdbGetDualReportStats library, calculated asynchronously in a Worker and sent back |
groupSummaryRecordService | Persists summary records (topic / trigger / log) |
It calls an OpenAI-compatible interface (aiModelApiBaseUrl/aiModelApiKey/aiModelApiModel); the endpoint is assembled by buildApiUrl(apiBaseUrl, '/chat/completions') with Authorization: Bearer <apiKey>, and the main process does the fetch directly, never putting secrets in the renderer process.
There's also special handling for the mimo model, and wcdbCloudInit/CloudReport/CloudStop (a switch for anonymous statistics reporting that only takes effect after explicit user consent)—a privacy design that asks before sending.
10. The HTTP API and External Integration
httpService.ts (98k bytes) is a hand-rolled, minimal Node http server (no Express), on port 5031 by default, bound to 127.0.0.1:
// electron/services/httpService.ts — endpoint mapping /health /api/v1/*
if (pathname === '/health' || pathname === '/api/v1/health') handleHealth()
else if (pathname === '/api/v1/push/messages') handleSse(res)
else if (pathname === '/api/v1/messages') handleMessages(req, res)
else if (pathname === '/api/v1/sessions') handleSessions(...)
else if (pathname.startsWith('/api/v1/sessions/') ...) // ChatLab Pull
else if (pathname === '/api/v1/contacts') handleContacts(...)
else if (pathname === '/api/v1/group-members') handleGroupMembers(...)
else if (pathname === '/api/v1/sns/timeline' ...) handleSns*(...)
else if (pathname.startsWith('/api/v1/media/')) handleMedia(...)
Highlights:
- Warm up the database and the markdown-mapping thread pool at startup, so the first big requests don't drop whole pages because of the native library's cold cache:
this.server.listen(this.port, this.host, () => {
void this.ensureDbReady().catch(...) // warm up wcdb
try { this.getApiMapperPool().warmup() } catch {} // warm up the mapper worker pool
this.startMessagePushHeartbeat()
})
- A message-mapping thread pool:
apiMessageMapperPooloffloads "row → Message" decoding to Workers scaled to the core count, enabled above a 300-row threshold, falling back to the main thread on failure. - SSE push
GET /api/v1/push/messages: a long-lived connection with event namesmessage.new / message.revoke; after a reconnect, missed events are replayed frommessagePushReplayBuffer(TTL 10min, cap 1000 entries), deduped byevent + rawid. - Auth tokens: supports
Authorization: Bearer,?access_token=, and a JSON body—three ways—except for the health check.
11. Renderer (React) Architecture
11.1 Pages and Routing
src/App.tsx (816 lines) uses React Router with everything lazy-loaded:
// Every page is lazy-loaded: the main window's first paint only parses the App shell + HomePage;
const ChatPage = lazy(() => import('./pages/ChatPage'))
const SnsPage = lazy(() => import('./pages/SnsPage'))
const NotificationWindow = lazy(() => import('./pages/NotificationWindow'))
// Standalone windows (possibly one SPA across multiple BrowserWindows)
const isNotificationWindow = location.pathname === '/notification-window'
const isAnnualReportWindow = location.pathname === '/annual-report/view'
A key architectural pattern: one SPA, many BrowserWindows. All child windows (notification / video playback / annual-report viewer / image viewer / agreements, etc.) are the same index.html with different query strings, branching on location.pathname to decide which page to render. The upside is a shared preload/bundle; the downside is that page files must stay small and be split into lazy-loaded chunks.
The Export module is mounted on demand (exportMounted state): even if you never visit the export page, it mounts whenever an enabled automation task exists (the 30-second scheduler lives inside the export page). This "route-triggered + conditional mount" design reduces resident memory.
11.2 State Management
On top of nine Zustand stores, chatStore acts as the cross-database deduper (see §4.2). All renderer config flows through src/services/config.ts, a 2,363-line "config API facade" that centrally exports 300+ CONFIG_KEYS (security, notifications, AI, export presets, backup, HTTP API, etc.).
11.3 Splitting the Oversized Pages
The four biggest pages (ChatPage.tsx at 515k bytes, SettingsPage.tsx at 247k, SnsPage.tsx at 141k, ResourcesPage.tsx at 113k) all follow the same hook + utils + component split:
src/pages/Export/
├── ExportPage.tsx # top-level orchestration
├── components/
│ ├── ExportDialog/index.tsx # export dialog
│ ├── SessionTable/index.tsx # session table (react-virtuoso virtualization)
│ ├── TaskCenter/index.tsx # background task center
│ └── Automation/… # scheduled export
├── hooks/ # useExportConfig / useExportSessions / useAutomation…
├── utils/ # avatar.ts / format.ts / performance.ts
└── constants.ts
Among them, react-virtuoso handles million-message streams, echarts(-for-react) handles analytics/report visualization, react-markdown + remark-gfm renders AI insight content, html2canvas exports reports as images, jszip handles backup archives, and jieba-wasm does tokenization to power search and word clouds.
12. Visualization and Desktop Effects
WeFlow has a full self-built "glass texture" effects stack (src/components/LiquidGlass/*, including glassStreamRenderer.ts for WebGL stream rendering and glassMotionEstimator.ts, where motion estimation drives refraction intensity), backed by the @hicccc77/electron-liquid-glass native addon (on the main-process side).
Windows system notifications use dual-track rendering:
- Primary path: a native panel (Windows
Acrylic/NativePanel); the main process draws the frosted-glass card withGlassPanelfromnotificationWindow.ts; - Fallback path: Chromium captures the desktop stream, with
webrtc-max-cpu-consumption-percentage=100raising the capture-rate ceiling, and the renderer'sLiquidGlasshandles the displacement mapping.
// electron/main.ts
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100')
// but the fallback capture only runs for the few seconds the notification is shown, and the resolution is already reduced to logical size
The renderer reports the card's measured geometry (notification:glassRect) plus a luminance-band callback (notification:luma) to the main process; the native layer uses that to tune refraction parameters dynamically, keeping the notification card readable on both light and dark desktops (useNotificationAdaptiveTheme.ts, systemNotificationService).
13. Packaging, Distribution, and Updates
13.1 electron-builder Multi-Platform Resource Mapping
mac: extraResources: [ wcdb/macos/universal, wedecrypt/macos/$arch, key/macos/universal, welive/macos/$arch ]
win: extraFiles: [ MSVC runtime dll ]
extraResources: [ wcdb/win32/$arch, wedecrypt/win32/$arch, key/win32, welive/win32/$arch ]
linux: extraResources: [ wcdb/linux/$arch, wedecrypt/linux/x64, key/linux, welive/linux/$arch ]
asarUnpack: [ silk-wasm, sherpa-onnx-*, ffmpeg-static, electron-liquid-glass, wedecrypt/**/*.node ]
The key challenge: a three-way matrix of platform × architecture × asar packaging. The approach:
- Native addons all go through
asarUnpack, with path resolution wrapped in three utility functions:addonCandidates()/resolveWorkerPath()/resolveWeliveExecutable(); after-pack.cjshandles.dyliblinking specially on macOS (install_name_tool -change @rpath/WCDB.framework/... @loader_path/libWCDB.dylib);- Windows ships runtimes like
msvcp140.dllso users don't need VC++ installed.
13.2 Auto-Update
// electron/main.ts — update channel inference
const inferUpdateTrackFromVersion = (version) => {
if (/^0\.(\d{2})\.(\d+)$/.test(normalized)) return 'preview' // 0.YY.N
if (/^\d{2}(\.\d{1,2}){2}$/.test(normalized)) return 'dev' // YY.M.D
if (/(alpha|beta|rc)/i.test(version)) return 'dev'
return 'stable'
}
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // disable diffs, force a full download
The release source is GitHub Releases (publish: { provider: 'github' }). The UI has UpdateDialog (a modal) and UpdateProgressCapsule (a download capsule). The version number determines the default channel (stable / preview / dev), and Win x64 / arm64 are distinguished so their manifests don't collide.
14. Summary of Key Design Trade-offs
| Dimension | Decision | Rationale |
|---|---|---|
| Data reading | Load WeChat's own WCDB C API + koffi FFI instead of writing our own SQLCipher decryption | Lowest cost to keep in sync with WeChat 4.0+ schema changes, best key compatibility |
| Compute isolation | 9 worker_threads Workers instead of utilityProcess | Thread-level isolation is enough, saving process overhead; a crashed Worker can auto-restart |
| Real-time | Database watching lives inside the C++ data service; the main process just connects to the pipe | Avoids duplicate watchers across windows tying up the JS thread |
| Cross-database consistency | Primary message key + multi-alias-key dedup (lid/sid + _db_path scope) | WeChat's sharding makes serverId/localId not globally unique, and a wrong merge loses messages |
| Key security | All key extraction goes through separate native DLLs (wx_key.dll, wedecrypt, welive), with the main process only orchestrating | Isolates OS-API injection logic for easier cross-platform/multi-arch distribution and maintenance; core algorithms can evolve over time |
| Export | Strategy pattern + 8 Formatters, one Worker thread pool + an optional Rust welive engine running alongside | Keeps the extensibility of pure TS while using Rust to hit the disk-throughput ceiling |
| AI | No built-in key; local orchestration + a user-supplied OpenAI-compatible endpoint; prompts in shared/ | Zero server footprint; prompts reused on both ends |
| Memory | All pages lazy, the Export module mounted on demand, big pages split into hooks | The single-SPA / multi-window model is memory-sensitive (the JS heap bloats easily with lots of messages) |
| Desktop experience | Native GlassPanel (Addon) as primary + Chromium capture as fallback | On Windows, a native composited panel uses far less power than continuous system-wide screen capture |
| Reliability | Watch-pipe auto-reconnect, Worker auto-rebuild, incremental baseline + revoke rescan, negative caching | Handles failure scenarios like the WCDB file watcher dropping out and concurrent export pause/cancel |
Technical Points Worth Learning From
- Unify the three reverse-engineered key algorithms into a single deterministic
derive+ verify model: a code/wxid candidate combination only lands if it passes verification against real ciphertext, eliminating bad derivations. - One copy of the service code, reused by both the main process and the Worker: a Vite virtual module swaps the single
import 'electron', letting backend services work naturally in both runtime environments. - A four-field message protocol for Worker RPC + duplex callbacks: cleverly reserving
id=-1for "server-initiated push" semantics keeps the code extremely short. - SSE replay buffer + TTL + event dedup: lets external consumers avoid missing key events after a subscription restart, with no extra complexity.
webSecurity:falseonly on the windows that genuinely need local video files, never globally—a fine-grained security trade-off.
Limitations and Room for Improvement
- The codebase has thousand-line monster files (
chatService.ts≈ 12.8k lines,ChatPage.tsx≈ 515k bytes), and services are coupled through global singletons, so future module splitting / bundle splitting will be costly; - There's no automated test system (no
testscript), and regression testing is mostly manual; - i18n / a11y is largely absent at the
detaillevel (installerLanguagescovers only zhCN / enUS); - The key-injection approach inherently depends on WeChat's non-hardened runtime memory—after a WeChat update,
keyService*is the most fragile and first-to-break link.
This document was generated from a static analysis of the repository source (2026-09). Line numbers and byte sizes will drift between versions; the code snippets quoted are excerpts from the original, trimmed for readability.