Compare commits

...

6 Commits

  • feat(sns): 朋友圈页支持联系人侧栏、导出与 Live Photo
    - 左侧新增朋友圈联系人列表(按发圈数),支持搜索与“全部/单人”筛选
    - 新增“导出全部/导出此人”,展示导出状态并支持下载 ZIP(SSE 优先,轮询兜底)
    - Live Photo/实况:悬停播放、静音切换与预览弹窗
    - 媒体请求统一透传 use_cache;关闭缓存时追加时间戳避免浏览器缓存
  • feat(settings): 增加朋友圈图片使用缓存开关
    - 新增本地设置项 sns.settings.useCache(默认开启)
    - 设置页增加“朋友圈图片使用缓存”开关与说明,用于控制下载解密失败时的缓存回退策略
  • feat(sns-export): 支持朋友圈 HTML 离线导出(ZIP)
    - 新增导出任务:创建/查询/取消/下载 ZIP
    - 提供 SSE 进度流 /api/sns/exports/{id}/events(用于前端实时展示进度)
    - 复用聊天导出 CSS/emoji 能力,导出媒体优先本地缓存,必要时远程下载解密
    - 后端 app 注册 sns_export 路由
  • feat(sns): 增强朋友圈时间线/媒体获取与实时同步
    - 新增 /api/sns/users:按发圈数统计联系人(支持 keyword/limit)
    - 新增 /api/sns/realtime/sync_latest:WCDB 实时增量同步到解密库(append-only),并持久化 sync state
    - 朋友圈媒体优先走“远程下载+解密”:图片支持 wcdb_decrypt_sns_image,视频/实况支持 ISAAC64(WeFlow 逻辑)
    - 增加 WeFlow WASM keystream(Node) 优先 + Python ISAAC64 fallback,提升兼容性
    - wcdb_api.dll 支持多路径自动发现/环境变量覆盖,并在状态信息中回传实际使用路径
19 changed files with 5448 additions and 172 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"devDependencies": {
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "0.2.1",
"version": "1.3.0",
"main": "src/main.cjs",
"scripts": {
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
+29
View File
@@ -0,0 +1,29 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Keep the SVG identical to WeFlow/src/components/LivePhotoIcon.tsx for visual consistency -->
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g stroke="currentColor" stroke-width="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5" />
<circle cx="12" cy="12" r="5.5" />
<circle cx="12" cy="12" r="9" stroke-dasharray="1 3.7" />
</g>
</g>
</svg>
</template>
<script setup>
defineProps({
size: {
type: [Number, String],
default: 24
}
})
</script>
+39
View File
@@ -236,6 +236,16 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈联系人列表(按发圈数统计)
const listSnsUsers = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.keyword) query.set('keyword', String(params.keyword))
if (params && params.limit != null) query.set('limit', String(params.limit))
const url = '/sns/users' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
@@ -356,6 +366,31 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
body: {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
}
})
}
const getSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`)
}
const cancelSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 联系人
const listChatContacts = async (params = {}) => {
const query = new URLSearchParams()
@@ -454,6 +489,7 @@ export const useApi = () => {
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline,
listSnsUsers,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
@@ -465,6 +501,9 @@ export const useApi = () => {
getChatExport,
listChatExports,
cancelChatExport,
createSnsExport,
getSnsExport,
cancelSnsExport,
listChatContacts,
exportChatContacts,
getWrappedAnnual,
+29 -1
View File
@@ -89,6 +89,26 @@
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">朋友圈</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">朋友圈图片使用缓存</div>
<div class="text-xs text-gray-500">开启下载解密失败时回退本地缓存默认开启关闭每次都走下载+解密</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="snsUseCache"
@change="onSnsUseCacheToggle"
/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -98,7 +118,7 @@
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
useHead({ title: '设置 - 微信数据分析助手' })
@@ -106,6 +126,7 @@ const isDesktopEnv = ref(false)
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const snsUseCache = ref(true)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
@@ -198,6 +219,12 @@ const onDesktopDefaultToChatToggle = (ev) => {
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
}
const onSnsUseCacheToggle = (ev) => {
const checked = !!ev?.target?.checked
snsUseCache.value = checked
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked)
}
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
isDesktopEnv.value = !!window.wechatDesktop
@@ -205,6 +232,7 @@ onMounted(async () => {
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
if (isDesktopEnv.value) {
await refreshDesktopAutoLaunch()
+966 -55
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
export const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
export const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
// 朋友圈图片:是否允许使用缓存(默认开启)。关闭后会尽量每次都走下载+解密流程。
export const SNS_SETTING_USE_CACHE_KEY = 'sns.settings.useCache'
export const readLocalBoolSetting = (key, fallback = false) => {
if (!process.client) return !!fallback
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
+2 -2
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "0.1.0"
__author__ = "WeChat Decrypt Tool"
__version__ = "1.3.0"
__author__ = "WeChat Decrypt Tool"
+4 -1
View File
@@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from . import __version__ as APP_VERSION
from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
@@ -21,6 +22,7 @@ from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -32,7 +34,7 @@ logger = get_logger(__name__)
app = FastAPI(
title="微信数据库解密工具",
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
version="0.1.0",
version=APP_VERSION,
)
# 设置自定义路由类
@@ -57,6 +59,7 @@ app.include_router(_chat_contacts_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
"""ISAAC-64 PRNG (WeFlow compatible).
WeChat SNS live photo/video decryption uses a keystream generated by ISAAC-64 and
XORs the first 128KB of the mp4 file. WeFlow's implementation reverses the
generated byte array, so we mirror that behavior for compatibility.
"""
from typing import Any
_MASK_64 = 0xFFFFFFFFFFFFFFFF
def _u64(v: int) -> int:
return int(v) & _MASK_64
class Isaac64:
def __init__(self, seed: Any):
seed_text = str(seed).strip()
if not seed_text:
seed_val = 0
else:
try:
# WeFlow seeds with BigInt(seed), where seed is usually a decimal string.
seed_val = int(seed_text, 0)
except Exception:
seed_val = 0
self.mm = [_u64(0) for _ in range(256)]
self.aa = _u64(0)
self.bb = _u64(0)
self.cc = _u64(0)
self.randrsl = [_u64(0) for _ in range(256)]
self.randrsl[0] = _u64(seed_val)
self.randcnt = 0
self._init(True)
def _init(self, flag: bool) -> None:
a = b = c = d = e = f = g = h = _u64(0x9E3779B97F4A7C15)
def mix() -> tuple[int, int, int, int, int, int, int, int]:
nonlocal a, b, c, d, e, f, g, h
a = _u64(a - e)
f = _u64(f ^ (h >> 9))
h = _u64(h + a)
b = _u64(b - f)
g = _u64(g ^ _u64(a << 9))
a = _u64(a + b)
c = _u64(c - g)
h = _u64(h ^ (b >> 23))
b = _u64(b + c)
d = _u64(d - h)
a = _u64(a ^ _u64(c << 15))
c = _u64(c + d)
e = _u64(e - a)
b = _u64(b ^ (d >> 14))
d = _u64(d + e)
f = _u64(f - b)
c = _u64(c ^ _u64(e << 20))
e = _u64(e + f)
g = _u64(g - c)
d = _u64(d ^ (f >> 17))
f = _u64(f + g)
h = _u64(h - d)
e = _u64(e ^ _u64(g << 14))
g = _u64(g + h)
return a, b, c, d, e, f, g, h
for _ in range(4):
mix()
for i in range(0, 256, 8):
if flag:
a = _u64(a + self.randrsl[i])
b = _u64(b + self.randrsl[i + 1])
c = _u64(c + self.randrsl[i + 2])
d = _u64(d + self.randrsl[i + 3])
e = _u64(e + self.randrsl[i + 4])
f = _u64(f + self.randrsl[i + 5])
g = _u64(g + self.randrsl[i + 6])
h = _u64(h + self.randrsl[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
if flag:
for i in range(0, 256, 8):
a = _u64(a + self.mm[i])
b = _u64(b + self.mm[i + 1])
c = _u64(c + self.mm[i + 2])
d = _u64(d + self.mm[i + 3])
e = _u64(e + self.mm[i + 4])
f = _u64(f + self.mm[i + 5])
g = _u64(g + self.mm[i + 6])
h = _u64(h + self.mm[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
self._isaac64()
self.randcnt = 256
def _isaac64(self) -> None:
self.cc = _u64(self.cc + 1)
self.bb = _u64(self.bb + self.cc)
for i in range(256):
x = self.mm[i]
if (i & 3) == 0:
# aa ^= ~(aa << 21)
self.aa = _u64(self.aa ^ (_u64(self.aa << 21) ^ _MASK_64))
elif (i & 3) == 1:
self.aa = _u64(self.aa ^ (self.aa >> 5))
elif (i & 3) == 2:
self.aa = _u64(self.aa ^ _u64(self.aa << 12))
else:
self.aa = _u64(self.aa ^ (self.aa >> 33))
self.aa = _u64(self.mm[(i + 128) & 255] + self.aa)
y = _u64(self.mm[(x >> 3) & 255] + self.aa + self.bb)
self.mm[i] = y
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
self.randrsl[i] = self.bb
def get_next(self) -> int:
if self.randcnt == 0:
self._isaac64()
self.randcnt = 256
idx = 256 - self.randcnt
self.randcnt -= 1
return _u64(self.randrsl[idx])
def generate_keystream(self, size: int) -> bytes:
"""Generate a keystream of `size` bytes (must be multiple of 8)."""
if size <= 0:
return b""
if size % 8 != 0:
raise ValueError("ISAAC64 keystream size must be multiple of 8 bytes.")
out = bytearray()
count = size // 8
for _ in range(count):
out.extend(int(self.get_next()).to_bytes(8, "little", signed=False))
# WeFlow reverses the entire byte array (Uint8Array.reverse()).
out.reverse()
return bytes(out)
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
import asyncio
import json
import time
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..path_fix import PathFixRoute
from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip)")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
)
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports", summary="列出导出任务(内存)")
async def list_sns_exports():
jobs = [j.to_public_dict() for j in SNS_EXPORT_MANAGER.list_jobs()]
jobs.sort(key=lambda x: int(x.get("createdAt") or 0), reverse=True)
return {"status": "success", "jobs": jobs}
@router.get("/api/sns/exports/{export_id}", summary="获取导出任务状态")
async def get_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports/{export_id}/download", summary="下载导出 zip")
async def download_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
if not job.zip_path or (not job.zip_path.exists()):
raise HTTPException(status_code=409, detail="Export not ready.")
return FileResponse(
str(job.zip_path),
media_type="application/zip",
filename=job.zip_path.name,
)
@router.get("/api/sns/exports/{export_id}/events", summary="导出任务进度 SSE")
async def stream_sns_export_events(export_id: str, request: Request):
export_id = str(export_id or "").strip()
job0 = SNS_EXPORT_MANAGER.get_job(export_id)
if not job0:
raise HTTPException(status_code=404, detail="Export not found.")
async def gen():
last_payload = ""
last_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
job = SNS_EXPORT_MANAGER.get_job(export_id)
if not job:
yield "event: error\ndata: " + json.dumps({"error": "Export not found."}, ensure_ascii=False) + "\n\n"
break
payload = json.dumps(job.to_public_dict(), ensure_ascii=False)
if payload != last_payload:
last_payload = payload
yield f"data: {payload}\n\n"
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
if job.status in {"done", "error", "cancelled"}:
break
await asyncio.sleep(0.6)
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@router.delete("/api/sns/exports/{export_id}", summary="取消导出任务")
async def cancel_sns_export(export_id: str):
ok = SNS_EXPORT_MANAGER.cancel_job(str(export_id or "").strip())
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
"""SNS (Moments) realtime -> decrypted sqlite incremental sync.
Why:
- We can read the latest Moments via WCDB realtime, but the decrypted snapshot (`output/databases/{account}/sns.db`)
can lag behind or miss data (e.g. you viewed it when it was visible, then it became "only last 3 days").
- For export/offline browsing, we want to keep a local append-only cache of Moments that were visible at some point.
This module runs a lightweight background poller that watches db_storage/sns*.db mtime changes and triggers a cheap
incremental sync of the latest N Moments into the decrypted snapshot.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from fastapi import HTTPException
from .chat_helpers import _list_decrypted_accounts, _resolve_account_dir
from .logging_config import get_logger
from .wcdb_realtime import WCDB_REALTIME
logger = get_logger(__name__)
def _env_bool(name: str, default: bool) -> bool:
raw = str(os.environ.get(name, "") or "").strip().lower()
if not raw:
return default
return raw not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
raw = str(os.environ.get(name, "") or "").strip()
try:
v = int(raw)
except Exception:
v = int(default)
if v < min_v:
v = min_v
if v > max_v:
v = max_v
return v
def _mtime_ns(path: Path) -> int:
try:
st = path.stat()
m_ns = int(getattr(st, "st_mtime_ns", 0) or 0)
if m_ns <= 0:
m_ns = int(float(getattr(st, "st_mtime", 0.0) or 0.0) * 1_000_000_000)
return int(m_ns)
except Exception:
return 0
def _scan_sns_db_mtime_ns(db_storage_dir: Path) -> int:
"""Best-effort "latest mtime" signal for sns.db buckets."""
base = Path(db_storage_dir)
candidates: list[Path] = [
base / "sns" / "sns.db",
base / "sns" / "sns.db-wal",
base / "sns" / "sns.db-shm",
base / "sns.db",
base / "sns.db-wal",
base / "sns.db-shm",
]
max_ns = 0
for p in candidates:
v = _mtime_ns(p)
if v > max_ns:
max_ns = v
return int(max_ns)
@dataclass
class _AccountState:
last_mtime_ns: int = 0
due_at: float = 0.0
last_sync_end_at: float = 0.0
thread: Optional[threading.Thread] = None
class SnsRealtimeAutoSyncService:
def __init__(self) -> None:
self._enabled = _env_bool("WECHAT_TOOL_SNS_AUTOSYNC", True)
self._interval_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_INTERVAL_MS", 2000, min_v=500, max_v=60_000)
self._debounce_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_DEBOUNCE_MS", 800, min_v=0, max_v=60_000)
self._min_sync_interval_ms = _env_int(
"WECHAT_TOOL_SNS_AUTOSYNC_MIN_SYNC_INTERVAL_MS", 5000, min_v=0, max_v=300_000
)
self._workers = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_WORKERS", 1, min_v=1, max_v=4)
self._max_scan = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_MAX_SCAN", 200, min_v=20, max_v=2000)
self._mu = threading.Lock()
self._states: dict[str, _AccountState] = {}
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if not self._enabled:
logger.info("[sns-autosync] disabled by env WECHAT_TOOL_SNS_AUTOSYNC=0")
return
with self._mu:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
th = threading.Thread(target=self._run, name="sns-realtime-autosync", daemon=True)
self._thread = th
th.start()
logger.info(
"[sns-autosync] started interval_ms=%s debounce_ms=%s min_sync_interval_ms=%s max_scan=%s workers=%s",
int(self._interval_ms),
int(self._debounce_ms),
int(self._min_sync_interval_ms),
int(self._max_scan),
int(self._workers),
)
def stop(self) -> None:
self._stop.set()
with self._mu:
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
tick_t0 = time.perf_counter()
try:
self._tick()
except Exception:
logger.exception("[sns-autosync] tick failed")
elapsed_ms = (time.perf_counter() - tick_t0) * 1000.0
sleep_ms = max(200.0, float(self._interval_ms) - elapsed_ms)
self._stop.wait(timeout=sleep_ms / 1000.0)
def _tick(self) -> None:
accounts = _list_decrypted_accounts()
now = time.time()
if not accounts:
return
for acc in accounts:
if self._stop.is_set():
break
try:
account_dir = _resolve_account_dir(acc)
except HTTPException:
continue
except Exception:
continue
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
continue
db_storage_dir = Path(str(info.get("db_storage_dir") or "").strip())
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
continue
mtime_ns = _scan_sns_db_mtime_ns(db_storage_dir)
with self._mu:
st = self._states.setdefault(acc, _AccountState())
if mtime_ns and mtime_ns != st.last_mtime_ns:
st.last_mtime_ns = int(mtime_ns)
st.due_at = now + (float(self._debounce_ms) / 1000.0)
# Schedule daemon threads.
to_start: list[threading.Thread] = []
with self._mu:
keep = set(accounts)
for acc in list(self._states.keys()):
if acc not in keep:
self._states.pop(acc, None)
running = 0
for st in self._states.values():
th = st.thread
if th is not None and th.is_alive():
running += 1
elif th is not None and (not th.is_alive()):
st.thread = None
for acc, st in self._states.items():
if running >= int(self._workers):
break
if st.due_at <= 0 or st.due_at > now:
continue
if st.thread is not None and st.thread.is_alive():
continue
since = now - float(st.last_sync_end_at or 0.0)
min_interval = float(self._min_sync_interval_ms) / 1000.0
if min_interval > 0 and since < min_interval:
st.due_at = now + (min_interval - since)
continue
st.due_at = 0.0
th = threading.Thread(
target=self._sync_account_runner,
args=(acc,),
name=f"sns-autosync-{acc}",
daemon=True,
)
st.thread = th
to_start.append(th)
running += 1
for th in to_start:
if self._stop.is_set():
break
try:
th.start()
except Exception:
with self._mu:
for acc, st in self._states.items():
if st.thread is th:
st.thread = None
break
def _sync_account_runner(self, account: str) -> None:
account = str(account or "").strip()
try:
if self._stop.is_set() or (not account):
return
res = self._sync_account(account)
upserted = int((res or {}).get("upserted") or 0)
logger.info("[sns-autosync] sync done account=%s upserted=%s", account, upserted)
except Exception:
logger.exception("[sns-autosync] sync failed account=%s", account)
finally:
with self._mu:
st = self._states.get(account)
if st is not None:
st.thread = None
st.last_sync_end_at = time.time()
def _sync_account(self, account: str) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {"status": "skipped", "reason": "missing account"}
try:
account_dir = _resolve_account_dir(account)
except Exception as e:
return {"status": "skipped", "reason": f"resolve account failed: {e}"}
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
return {"status": "skipped", "reason": "realtime not available"}
# Import lazily to avoid startup import ordering issues.
from .routers.sns import sync_sns_realtime_timeline_latest
try:
return sync_sns_realtime_timeline_latest(
account=account,
max_scan=int(self._max_scan),
force=0,
)
except HTTPException as e:
return {"status": "error", "error": str(e.detail or "")}
except Exception as e:
return {"status": "error", "error": str(e)}
SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService()
+129 -6
View File
@@ -1,6 +1,8 @@
import ctypes
import binascii
import json
import os
import re
import sys
import threading
import time
@@ -20,7 +22,51 @@ class WCDBRealtimeError(RuntimeError):
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _candidate_wcdb_api_dll_paths() -> list[Path]:
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
cands: list[Path] = []
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
if env:
cands.append(Path(env))
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
try:
repo_root = Path(__file__).resolve().parents[2]
except Exception:
repo_root = Path.cwd()
for p in [
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
_DEFAULT_WCDB_API_DLL,
]:
if p not in cands:
cands.append(p)
return cands
def _resolve_wcdb_api_dll_path() -> Path:
global _WCDB_API_DLL_SELECTED
if _WCDB_API_DLL_SELECTED is not None:
return _WCDB_API_DLL_SELECTED
for p in _candidate_wcdb_api_dll_paths():
try:
if p.exists() and p.is_file():
_WCDB_API_DLL_SELECTED = p
return p
except Exception:
continue
# Fall back to the default path even if it doesn't exist; caller will raise a clear error.
_WCDB_API_DLL_SELECTED = _DEFAULT_WCDB_API_DLL
return _WCDB_API_DLL_SELECTED
_lib_lock = threading.Lock()
_lib: Optional[ctypes.CDLL] = None
@@ -40,16 +86,18 @@ def _load_wcdb_lib() -> ctypes.CDLL:
if not _is_windows():
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
if not _WCDB_API_DLL.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {_WCDB_API_DLL}")
wcdb_api_dll = _resolve_wcdb_api_dll_path()
if not wcdb_api_dll.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {wcdb_api_dll}")
# Ensure dependent DLLs (e.g. WCDB.dll) can be found.
try:
os.add_dll_directory(str(_NATIVE_DIR))
os.add_dll_directory(str(wcdb_api_dll.parent))
except Exception:
pass
lib = ctypes.CDLL(str(_WCDB_API_DLL))
lib = ctypes.CDLL(str(wcdb_api_dll))
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
# Signatures
lib.wcdb_init.argtypes = []
@@ -144,6 +192,19 @@ def _load_wcdb_lib() -> ctypes.CDLL:
# Older wcdb_api.dll may not expose this export.
pass
# Optional (newer DLLs): wcdb_decrypt_sns_image(encrypted_data, len, key, out_hex)
# WeFlow uses this to decrypt Moments CDN images.
try:
lib.wcdb_decrypt_sns_image.argtypes = [
ctypes.c_void_p,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_void_p),
]
lib.wcdb_decrypt_sns_image.restype = ctypes.c_int32
except Exception:
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -488,6 +549,63 @@ def get_sns_timeline(
return []
def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
"""Decrypt Moments CDN image bytes using WCDB DLL (WeFlow compatible).
Notes:
- Requires a newer wcdb_api.dll export: wcdb_decrypt_sns_image.
- On failure, returns the original encrypted_data (best-effort behavior like WeFlow).
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_decrypt_sns_image", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.")
raw = bytes(encrypted_data or b"")
if not raw:
return b""
k = str(key or "").strip()
if not k:
return raw
out_ptr = ctypes.c_void_p()
buf = ctypes.create_string_buffer(raw, len(raw))
rc = 0
try:
rc = int(
fn(
ctypes.cast(buf, ctypes.c_void_p),
ctypes.c_int32(len(raw)),
k.encode("utf-8"),
ctypes.byref(out_ptr),
)
)
if rc != 0 or not out_ptr.value:
return raw
hex_bytes = ctypes.cast(out_ptr, ctypes.c_char_p).value or b""
if not hex_bytes:
return raw
# Defensive: keep only hex chars (some builds may include whitespace).
hex_clean = re.sub(rb"[^0-9a-fA-F]", b"", hex_bytes)
if not hex_clean:
return raw
try:
return binascii.unhexlify(hex_clean)
except Exception:
return raw
finally:
try:
if out_ptr.value:
lib.wcdb_free_string(ctypes.cast(out_ptr, ctypes.c_char_p))
except Exception:
pass
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -573,11 +691,16 @@ class WCDBRealtimeManager:
except Exception as e:
err = str(e)
dll_ok = _WCDB_API_DLL.exists()
dll_path = _resolve_wcdb_api_dll_path()
try:
dll_ok = bool(dll_path.exists())
except Exception:
dll_ok = False
connected = self.is_connected(account)
return {
"account": account,
"dll_present": bool(dll_ok),
"wcdb_api_dll": str(dll_path),
"key_present": bool(key_ok),
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
"session_db_path": str(session_db_path) if session_db_path else "",
+122
View File
@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
Generated
+1 -1
View File
@@ -866,7 +866,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },