mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
6 Commits
Generated
+2
-2
@@ -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,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 .\"",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
__version__ = "1.3.0"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
+2012
-102
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()
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user