feat(import): prepare native parser rollout

This commit is contained in:
digua committed 2026-07-05 22:49:38 +08:00
1 parent c265925e3e
commit c0548f63c8
23 files changed
+881 -764

No files matched your search

+49
View File
@@ -105,6 +105,31 @@ jobs:
echo "node-linker=hoisted" >> .npmrc
pnpm install --frozen-lockfile
# Rust native parser:目标架构与 electron-builder --x64/--arm64 保持一致
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.arch == 'x64' && 'x86_64-apple-darwin' || 'aarch64-apple-darwin' }}
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/parser-native
key: ${{ matrix.arch }}
- name: Build native parser (${{ matrix.arch }})
shell: bash
run: |
if [ "${{ matrix.arch }}" == "x64" ]; then
pnpm --filter @openchatlab/parser-native build -- --target x86_64-apple-darwin
else
pnpm --filter @openchatlab/parser-native build
fi
- name: Verify native parser artifact
shell: bash
run: ls -la packages/parser-native/chatlab-parser-native.darwin-${{ matrix.arch }}.node
- name: Inject version into app package.json
shell: bash
run: |
@@ -151,6 +176,10 @@ jobs:
working-directory: apps/desktop
run: pnpm run build && pnpm exec electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml -p never
- name: Verify native parser in packaged app (${{ matrix.arch }})
shell: bash
run: node scripts/check-desktop-native.mjs darwin-${{ matrix.arch }}
- name: Upload macOS artifacts (${{ matrix.arch }})
uses: actions/upload-artifact@v6
with:
@@ -202,6 +231,22 @@ jobs:
echo "node-linker=hoisted" >> .npmrc
pnpm install --frozen-lockfile
# Rust native parser:Windows release 仅构建 x64(与 electron-builder --win 默认一致)
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/parser-native
- name: Build native parser (win x64)
run: pnpm --filter @openchatlab/parser-native build
- name: Verify native parser artifact
shell: bash
run: ls -la packages/parser-native/chatlab-parser-native.win32-x64-msvc.node
- name: Inject version into app package.json
shell: pwsh
run: |
@@ -236,6 +281,10 @@ jobs:
working-directory: apps/desktop
run: pnpm run build && pnpm exec electron-builder --win --config electron-builder.yml -p never
- name: Verify native parser in packaged app (win x64)
shell: bash
run: node scripts/check-desktop-native.mjs win32-x64-msvc
- name: Upload Windows artifacts
uses: actions/upload-artifact@v6
with:
+75
View File
@@ -0,0 +1,75 @@
name: Test
on:
pull_request:
push:
branches:
- main
- 'dev-*'
permissions:
contents: read
# 同一分支的旧运行自动取消,避免排队浪费
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24'
package-manager-cache: false
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/parser-native
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Rust unit tests (parser-native)
working-directory: packages/parser-native
run: cargo test
# Debug 构建足够触发 TS 侧 parity 测试(native 缺失时这些测试会自动 skip,
# 构建后即可在 CI 上真实覆盖 Rust/TS 双路径一致性)。
- name: Build native parser (debug)
run: pnpm --filter @openchatlab/parser-native build:debug
- name: Unit & integration tests
run: pnpm test
- name: Type check (node)
run: pnpm run type-check:node
+9 -1
View File
@@ -8,7 +8,13 @@ import * as fs from 'fs'
import { execSync } from 'child_process'
import { Command } from 'commander'
import { DEFAULT_API_PORT, loadConfig, getConfigPath, setConfigField, ConfigSetError } from '@openchatlab/config'
import { AIChatManager, initAppLogger, appLogger, getSystemLogsDir } from '@openchatlab/node-runtime'
import {
AIChatManager,
initAppLogger,
appLogger,
getSystemLogsDir,
logNativeParserStatus,
} from '@openchatlab/node-runtime'
import { getVersion } from './version'
import { resolveCliPath } from './paths'
import { isPortAvailable, formatPortInUseError } from './http/port'
@@ -63,6 +69,8 @@ program
const { streamImport, detectFormat } = await import('./import')
const { dbManager, pathProvider } = initRuntime()
const nativeBinding = resolveNativeBinding()
// 记录 Rust native parser 可用性(本次导入是否走 Rust 内核)
logNativeParserStatus()
const format = detectFormat(file)
if (!format && !options.format) {
+3
View File
@@ -23,6 +23,7 @@ import {
createSemanticIndexWorkerRuntimeClient,
initAppLogger,
appLogger,
logNativeParserStatus,
} from '@openchatlab/node-runtime'
import type { ConfigStorage, SemanticIndexRuntime } from '@openchatlab/node-runtime'
import { createServer } from './server'
@@ -175,6 +176,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
initAppLogger(pathProvider.getLogsDir())
initServerAiLogger(pathProvider.getLogsDir())
appLogger.info('server', `HTTP server starting on ${host}:${port}`)
// 记录 Rust native parser 可用性(导入是否走 Rust 内核),便于按日志排查回退原因
logNativeParserStatus()
setAuthToken(token)
setRequireAuth(!!(options?.requireAuth ?? config.api.require_auth))
+4
View File
@@ -29,6 +29,10 @@ files:
# ${platform} -> darwin/linux/win32,${arch} -> x64/arm64,正好匹配该包目录结构。
- '!node_modules/onnxruntime-node/bin/napi-v6/**'
- 'node_modules/onnxruntime-node/bin/napi-v6/${platform}/${arch}/**'
# parser-native 是 workspace 内的 Rust 包:只需要 index.js/index.d.ts/package.json 和 *.node,
# 排除 Rust 源码与构建中间产物(CI 上 target/ 可达数百 MB)。
- '!node_modules/@openchatlab/parser-native/{target,src,node_modules}/**'
- '!node_modules/@openchatlab/parser-native/{Cargo.toml,Cargo.lock,build.rs,.gitignore}'
# 哪些文件将不会被压缩,而是解压到构建目录
# 仅含 .dylib(无 .node)的原生包不会被 electron-builder 自动解包,必须显式列出,
# 否则 dlopen 无法从 asar 内加载:
+3
View File
@@ -27,6 +27,7 @@ import {
} from './database/startup-migration'
import { initLocale } from './i18n'
import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config'
import { logNativeParserStatus } from '@openchatlab/node-runtime'
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat'
import {
@@ -99,6 +100,8 @@ class MainProcess {
async init() {
initAnalytics()
logger.info('Desktop app starting')
// 记录 Rust native parser 可用性(导入是否走 Rust 内核),便于按日志排查回退原因
logNativeParserStatus()
// E2E 测试模式:跳过遗留数据迁移
// 遗留迁移会删除 Documents/ChatLab,在本地测试时可能破坏用户数据
@@ -1,5 +1,6 @@
export { writeParseResultToDb } from './write-parse-result'
export type { ImportMeta, WriteParseResultStats } from './write-parse-result'
export { logNativeParserStatus } from './native-parser-status'
export {
LogLevel,
initPerfLog,
@@ -0,0 +1,29 @@
/**
* Startup observability for the optional Rust parsing kernels.
*
* Desktop main process and CLI entry points call this once at startup so
* `logs/app.log` always records whether imports will use the native kernels
* or the pure-TS fallback (and why). No user data is ever logged here.
*/
import { getNativeParserStatus } from '@openchatlab/parser'
import { appLogger } from '../logging/app-logger'
let logged = false
/** Log once per process whether the Rust native parser kernels can load. */
export function logNativeParserStatus(): void {
if (logged) return
logged = true
const status = getNativeParserStatus()
if (status.available) {
appLogger.info('import', 'Native parser kernels available (Rust module loaded)')
} else if (status.disabled) {
appLogger.info('import', 'Native parser kernels disabled via CHATLAB_DISABLE_NATIVE_PERF=1; using TS parsers')
} else {
appLogger.warn('import', 'Native parser kernels unavailable; imports fall back to TS parsers', {
reason: status.error ?? 'unknown',
})
}
}
+1
View File
@@ -15,6 +15,7 @@ export {
streamParseFileInfo,
analyzeIncrementalImport,
incrementalImport,
logNativeParserStatus,
ZipArchiveReader,
validateArchiveEntryName,
ArchiveImportError,
+21 -76
View File
@@ -1,41 +1,31 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* Rust ChatLab JSON parser. Strict about the format spec: off-spec files
* error out and the JS side falls back to the pure-TS parser.
* Usage from JS: `parse()` → `getMeta()` → `takeMembers()` → `takeBatch()` until null.
* Unified Rust parser for all native-first formats. Usage from JS:
* `parse()` → `metaJson()` → `takeMembers()` → `takeBatch()` until null →
* `summaryJson()`. Constructing with an unknown format id throws, which the
* TS wrapper treats like any other native failure (falls back to TS).
*/
export declare class ChatlabParser {
constructor(filePath: string)
export declare class NativeParser {
constructor(formatId: string, filePath: string, optionsJson?: string | undefined | null)
/** Run the full parse on the libuv thread pool. */
parse(): Promise<unknown>
/** Poll progress while `parse()` is pending. */
progress(): NativeParseProgress
getMeta(): NativeChatlabMeta
/** Format-specific meta as a JSON string (shape documented per kernel). */
metaJson(): string
/** Move members out to JS. Callable once. */
takeMembers(): Array<NativeChatlabMember>
takeMembers(): Array<NativeMember>
/** Move the next batch of messages out to JS; null when exhausted. */
takeBatch(size: number): Array<NativeChatlabMessage> | null
takeBatch(size: number): Array<NativeMessage> | null
/**
* Post-parse totals as JSON: `{"messageCount":n,"memberCount":n}`.
* Valid any time after `parse()`, even once the vectors are drained.
*/
summaryJson(): string
}
/**
* Rust WeFlow parser. Usage from JS:
* `parse()` → `getMeta()` → `takeMembers()` → `takeBatch()` until null.
*/
export declare class WeflowParser {
constructor(filePath: string)
/** Run the full parse on the libuv thread pool. */
parse(): Promise<unknown>
/** Poll progress while `parse()` is pending. */
progress(): NativeParseProgress
getMeta(): NativeWeflowMeta
/** Move members (with avatars) out to JS. Callable once. */
takeMembers(): Array<NativeParsedMember>
/** Move the next batch of messages out to JS; null when exhausted. */
takeBatch(size: number): Array<NativeParsedMessage> | null
}
export interface NativeChatlabMember {
export interface NativeMember {
platformId: string
accountName: string
groupNickname?: string
@@ -43,57 +33,23 @@ export interface NativeChatlabMember {
roles?: Array<NativeMemberRole>
}
export interface NativeChatlabMessage {
platformMessageId?: string
senderPlatformId: string
senderAccountName: string
senderGroupNickname?: string
timestamp: number
/** Numeric MessageType enum value from shared-types. */
messageType: number
/** undefined here means the source `content` was JSON null. */
content?: string
replyToMessageId?: string
}
export interface NativeChatlabMeta {
name: string
/** Passthrough of `meta.type` ("group" when missing), matching the TS parser. */
chatType: string
/** Passthrough of `meta.platform` ("unknown" when missing). */
platform: string
groupId?: string
groupAvatar?: string
/**
* true when members came from the top-level `members` array;
* false when collected from messages (TS emits different object shapes).
*/
membersFromHead: boolean
messageCount: number
memberCount: number
}
export interface NativeMemberRole {
id: string
/** Present only when the source role object had a `name` key. */
name?: string
}
export interface NativeParsedMember {
platformId: string
accountName: string
avatar?: string
}
export interface NativeParsedMessage {
platformMessageId: string
export interface NativeMessage {
platformMessageId?: string
senderPlatformId: string
senderAccountName: string
/** null when the source `createTime` was JSON null (importer skips those). */
senderGroupNickname?: string
/** None when the source timestamp was JSON null (importer skips those). */
timestamp?: number
/** Numeric MessageType enum value from shared-types. */
messageType: number
content?: string
replyToMessageId?: string
}
export interface NativeParseProgress {
@@ -101,14 +57,3 @@ export interface NativeParseProgress {
totalBytes: number
messagesProcessed: number
}
export interface NativeWeflowMeta {
name: string
/** "group" | "private" (ChatType enum string values) */
chatType: string
groupId?: string
groupAvatar?: string
ownerId?: string
messageCount: number
memberCount: number
}
+1 -2
View File
@@ -587,5 +587,4 @@ if (!nativeBinding) {
}
module.exports = nativeBinding
module.exports.ChatlabParser = nativeBinding.ChatlabParser
module.exports.WeflowParser = nativeBinding.WeflowParser
module.exports.NativeParser = nativeBinding.NativeParser
+66 -72
View File
@@ -11,50 +11,28 @@
use std::collections::HashMap;
use serde::Serialize;
use serde_json::Value;
use crate::input::KernelInput;
use crate::jsutil::{extract_name_from_file_path, non_empty_str};
use crate::protocol::{KernelOutput, NativeMember, NativeMemberRole, NativeMessage};
use crate::scanner::{for_each_array_element, walk_top_level, ScanError, ScanResult};
pub struct RoleOut {
pub id: String,
pub name: Option<String>,
}
pub struct ChatlabMemberOut {
pub platform_id: String,
pub account_name: String,
pub group_nickname: Option<String>,
pub avatar: Option<String>,
pub roles: Option<Vec<RoleOut>>,
}
#[derive(Default)]
pub struct ChatlabMessageOut {
pub platform_message_id: Option<String>,
pub sender_platform_id: String,
pub sender_account_name: String,
pub sender_group_nickname: Option<String>,
pub timestamp: f64,
pub message_type: u32,
/// None means the source `content` was JSON null.
pub content: Option<String>,
pub reply_to_message_id: Option<String>,
}
pub struct ChatlabOutput {
pub name: String,
/// Shape of `metaJson()` for the chatlab kernel (consumed by the TS adapter).
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ChatlabMeta {
name: String,
/// Passthrough of `meta.type` ("group" when missing/empty), like the TS parser.
pub chat_type: String,
chat_type: String,
/// Passthrough of `meta.platform` ("unknown" when missing/empty).
pub platform: String,
pub group_id: Option<String>,
pub group_avatar: Option<String>,
platform: String,
group_id: Option<String>,
group_avatar: Option<String>,
/// true: members came from the top-level `members` array (full shape);
/// false: collected from messages (id/name/nickname only), as in TS.
pub members_from_head: bool,
pub members: Vec<ChatlabMemberOut>,
pub messages: Vec<ChatlabMessageOut>,
members_from_head: bool,
}
fn strict(msg: impl Into<String>) -> ScanError {
@@ -176,7 +154,7 @@ fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult<MetaOut> {
})
}
fn parse_member(element: &[u8]) -> ScanResult<ChatlabMemberOut> {
fn parse_member(element: &[u8]) -> ScanResult<NativeMember> {
let value: Value =
serde_json::from_slice(element).map_err(|err| strict(format!("invalid member: {err}")))?;
let obj = value
@@ -196,7 +174,7 @@ fn parse_member(element: &[u8]) -> ScanResult<ChatlabMemberOut> {
if role.keys().any(|k| k != "id" && k != "name") {
return Err(strict("member role has unsupported keys"));
}
out.push(RoleOut {
out.push(NativeMemberRole {
id: require_str(role, "id", "member role")?,
name: optional_str(role, "name", "member role")?,
});
@@ -211,7 +189,7 @@ fn parse_member(element: &[u8]) -> ScanResult<ChatlabMemberOut> {
}
};
Ok(ChatlabMemberOut {
Ok(NativeMember {
platform_id: require_str(obj, "platformId", "member")?,
account_name: require_str(obj, "accountName", "member")?,
group_nickname: optional_str(obj, "groupNickname", "member")?,
@@ -255,9 +233,9 @@ impl CollectedMembers {
pub fn parse_chatlab(
buf: &[u8],
file_path: &str,
input: &KernelInput,
mut on_progress: impl FnMut(u64, u64),
) -> ScanResult<ChatlabOutput> {
) -> ScanResult<KernelOutput> {
let mut meta_raw: Option<&[u8]> = None;
let mut members_raw: Option<&[u8]> = None;
let mut messages_raw: Option<&[u8]> = None;
@@ -272,9 +250,9 @@ pub fn parse_chatlab(
Ok(())
})?;
let meta = parse_meta(meta_raw, file_path)?;
let meta = parse_meta(meta_raw, &input.primary_path)?;
let mut head_members: Vec<ChatlabMemberOut> = Vec::new();
let mut head_members: Vec<NativeMember> = Vec::new();
if let Some(raw) = members_raw {
let base_offset = raw.as_ptr() as usize - buf.as_ptr() as usize;
for_each_array_element(raw, base_offset, |element, _| {
@@ -285,7 +263,7 @@ pub fn parse_chatlab(
let members_from_head = !head_members.is_empty();
let mut collected = CollectedMembers::new();
let mut messages: Vec<ChatlabMessageOut> = Vec::new();
let mut messages: Vec<NativeMessage> = Vec::new();
if let Some(raw) = messages_raw {
let base_offset = raw.as_ptr() as usize - buf.as_ptr() as usize;
@@ -338,12 +316,12 @@ pub fn parse_chatlab(
collected.observe(&sender, &account_name, group_nickname.as_ref());
}
messages.push(ChatlabMessageOut {
messages.push(NativeMessage {
platform_message_id: optional_str(obj, "platformMessageId", "message")?,
sender_platform_id: sender,
sender_account_name: account_name,
sender_group_nickname: group_nickname,
timestamp,
timestamp: Some(timestamp),
message_type,
content,
reply_to_message_id: optional_str(obj, "replyToMessageId", "message")?,
@@ -360,25 +338,28 @@ pub fn parse_chatlab(
collected
.order
.into_iter()
.map(
|(platform_id, account_name, group_nickname)| ChatlabMemberOut {
platform_id,
account_name,
group_nickname,
avatar: None,
roles: None,
},
)
.map(|(platform_id, account_name, group_nickname)| NativeMember {
platform_id,
account_name,
group_nickname,
avatar: None,
roles: None,
})
.collect()
};
Ok(ChatlabOutput {
let meta_json = serde_json::to_string(&ChatlabMeta {
name: meta.name,
chat_type: meta.chat_type,
platform: meta.platform,
group_id: meta.group_id,
group_avatar: meta.group_avatar,
members_from_head,
})
.map_err(|err| strict(format!("meta serialization failed: {err}")))?;
Ok(KernelOutput {
meta_json,
members,
messages,
})
@@ -388,8 +369,16 @@ pub fn parse_chatlab(
mod tests {
use super::*;
fn parse(doc: &str) -> ScanResult<ChatlabOutput> {
parse_chatlab(doc.as_bytes(), "/tmp/示例群.json", |_, _| {})
fn parse(doc: &str) -> ScanResult<KernelOutput> {
let input = KernelInput {
primary_path: "/tmp/示例群.json".to_string(),
options_json: None,
};
parse_chatlab(doc.as_bytes(), &input, |_, _| {})
}
fn meta(out: &KernelOutput) -> Value {
serde_json::from_str(&out.meta_json).expect("meta_json should be valid JSON")
}
#[test]
@@ -408,11 +397,12 @@ mod tests {
]
}"#;
let out = parse(doc).expect("should parse");
assert_eq!(out.name, "测试群");
assert_eq!(out.platform, "weixin");
assert_eq!(out.chat_type, "group");
assert_eq!(out.group_id.as_deref(), Some("g1@chatroom"));
assert!(out.members_from_head);
let m = meta(&out);
assert_eq!(m["name"], "测试群");
assert_eq!(m["platform"], "weixin");
assert_eq!(m["chatType"], "group");
assert_eq!(m["groupId"], "g1@chatroom");
assert_eq!(m["membersFromHead"], true);
assert_eq!(out.members.len(), 2);
let roles = out.members[0].roles.as_ref().unwrap();
assert_eq!(roles[0].id, "owner");
@@ -422,6 +412,7 @@ mod tests {
assert_eq!(out.messages.len(), 3);
assert_eq!(out.messages[0].platform_message_id.as_deref(), Some("m1"));
assert_eq!(out.messages[0].timestamp, Some(100.0));
assert_eq!(out.messages[1].reply_to_message_id.as_deref(), Some("m1"));
assert_eq!(out.messages[2].content, None);
assert_eq!(out.messages[2].platform_message_id, None);
@@ -438,7 +429,7 @@ mod tests {
]}}"#
);
let out = parse(&doc).expect("should parse");
assert!(!out.members_from_head);
assert_eq!(meta(&out)["membersFromHead"], false);
assert_eq!(out.members.len(), 2);
// Whole entry replaced by the later message (JS Map.set semantics):
// groupNickname reset to None even though the first message had one.
@@ -450,16 +441,18 @@ mod tests {
#[test]
fn meta_defaults_and_filename_fallback() {
let out = parse(r#"{"meta": {}, "messages": []}"#).expect("should parse");
assert_eq!(out.name, "示例群");
assert_eq!(out.platform, "unknown");
assert_eq!(out.chat_type, "group");
assert_eq!(out.group_id, None);
let m = meta(&out);
assert_eq!(m["name"], "示例群");
assert_eq!(m["platform"], "unknown");
assert_eq!(m["chatType"], "group");
assert_eq!(m["groupId"], Value::Null);
// Explicit default name also falls back to the filename.
let out2 = parse(r#"{"meta": {"name": "未知群聊", "platform": "qq"}, "messages": []}"#)
.expect("should parse");
assert_eq!(out2.name, "示例群");
assert_eq!(out2.platform, "qq");
let m2 = meta(&out2);
assert_eq!(m2["name"], "示例群");
assert_eq!(m2["platform"], "qq");
// Missing meta -> strict error (TS emits a differently-shaped default
// meta object, so that path must go through the fallback).
@@ -493,11 +486,12 @@ mod tests {
{"sender": "", "accountName": "", "timestamp": 1, "type": 0, "content": ""}
]}"#;
let out = parse(doc).expect("should parse");
let m = meta(&out);
// Empty name is falsy -> default -> filename fallback; empty type -> group.
assert_eq!(out.name, "示例群");
assert_eq!(out.chat_type, "group");
assert_eq!(m["name"], "示例群");
assert_eq!(m["chatType"], "group");
// groupId passes through verbatim, including empty string.
assert_eq!(out.group_id.as_deref(), Some(""));
assert_eq!(m["groupId"], "");
assert_eq!(out.messages[0].sender_platform_id, "");
assert_eq!(out.messages[0].content.as_deref(), Some(""));
}
+23
View File
@@ -0,0 +1,23 @@
//! Kernel input abstraction (first iteration).
//!
//! Today a kernel gets one primary file plus the opaque options JSON blob
//! passed to the JS constructor. Multi-file formats (QQ chunked, Google Chat
//! Takeout) will extend this struct with base-dir scoped file access.
use std::io;
#[derive(Clone)]
pub struct KernelInput {
pub primary_path: String,
/// Format options from the JS side (e.g. telegram `chatIndex`); no
/// current kernel consumes it yet, but it is part of the stable N-API
/// constructor signature.
#[allow(dead_code)]
pub options_json: Option<String>,
}
impl KernelInput {
pub fn read_primary(&self) -> io::Result<Vec<u8>> {
std::fs::read(&self.primary_path)
}
}
+113 -312
View File
@@ -1,7 +1,9 @@
#![deny(clippy::all)]
mod chatlab;
mod input;
mod jsutil;
mod protocol;
mod scanner;
mod weflow;
@@ -11,31 +13,60 @@ use std::sync::{Arc, Mutex, MutexGuard};
use napi::bindgen_prelude::*;
use napi_derive::napi;
use chatlab::ChatlabOutput;
use weflow::{MessageOut, WeflowOutput};
use input::KernelInput;
use protocol::{KernelOutput, NativeMember, NativeMessage, NativeParseProgress};
#[napi(object)]
pub struct NativeParseProgress {
pub bytes_read: f64,
pub total_bytes: f64,
pub messages_processed: f64,
// ==================== Format registry ====================
/// Kernel ids understood by the `NativeParser` constructor. The TS side maps
/// sniffer format ids onto these (e.g. ycccccccy-echotrace reuses "weflow").
#[derive(Clone, Copy)]
enum FormatKind {
Weflow,
Chatlab,
}
impl FormatKind {
fn from_id(id: &str) -> Option<Self> {
match id {
"weflow" => Some(FormatKind::Weflow),
"chatlab" => Some(FormatKind::Chatlab),
_ => None,
}
}
fn run(
&self,
buf: &[u8],
input: &KernelInput,
on_progress: &mut dyn FnMut(u64, u64),
) -> std::result::Result<KernelOutput, scanner::ScanError> {
match self {
FormatKind::Weflow => weflow::parse_weflow(buf, input, on_progress),
FormatKind::Chatlab => chatlab::parse_chatlab(buf, input, on_progress),
}
}
}
// ==================== Shared parser state ====================
struct ParsedState<T> {
output: T,
struct ParsedState {
output: KernelOutput,
cursor: usize,
/// Totals captured at parse completion; `take_*` drains the vectors, so
/// `summary_json()` reports from these instead.
message_total: usize,
member_total: usize,
}
struct Shared<T> {
struct Shared {
bytes_read: AtomicU64,
total_bytes: AtomicU64,
messages_processed: AtomicU64,
parsed: Mutex<Option<ParsedState<T>>>,
parsed: Mutex<Option<ParsedState>>,
}
impl<T> Shared<T> {
impl Shared {
fn new() -> Arc<Self> {
Arc::new(Shared {
bytes_read: AtomicU64::new(0),
@@ -53,23 +84,16 @@ impl<T> Shared<T> {
}
}
fn lock_parsed(&self) -> Result<MutexGuard<'_, Option<ParsedState<T>>>> {
fn lock_parsed(&self) -> Result<MutexGuard<'_, Option<ParsedState>>> {
self.parsed
.lock()
.map_err(|_| Error::from_reason("parser state poisoned"))
}
/// Run a format kernel on the file, tracking progress atomics.
fn run_parse(
&self,
file_path: &str,
kernel: impl Fn(
&[u8],
&str,
&mut dyn FnMut(u64, u64),
) -> std::result::Result<T, scanner::ScanError>,
) -> Result<()> {
let buf = std::fs::read(file_path)
/// Run a format kernel on the primary file, tracking progress atomics.
fn run_parse(&self, format: FormatKind, input: &KernelInput) -> Result<()> {
let buf = input
.read_primary()
.map_err(|err| Error::from_reason(format!("failed to read file: {err}")))?;
self.total_bytes.store(buf.len() as u64, Ordering::Relaxed);
@@ -77,85 +101,52 @@ impl<T> Shared<T> {
self.bytes_read.store(bytes, Ordering::Relaxed);
self.messages_processed.store(messages, Ordering::Relaxed);
};
let output = kernel(&buf, file_path, &mut on_progress)
let output = format
.run(&buf, input, &mut on_progress)
.map_err(|err| Error::from_reason(format!("parse failed: {err}")))?;
self.bytes_read.store(buf.len() as u64, Ordering::Relaxed);
let mut parsed = self.lock_parsed()?;
*parsed = Some(ParsedState { output, cursor: 0 });
*parsed = Some(ParsedState {
message_total: output.messages.len(),
member_total: output.members.len(),
output,
cursor: 0,
});
Ok(())
}
}
/// Take the next `size` messages out of `messages`, advancing `cursor`.
fn take_batch_from<M: Default, O>(
state: &mut ParsedState<O>,
messages_of: impl Fn(&mut O) -> &mut Vec<M>,
size: u32,
map: impl Fn(M) -> M,
) -> Option<Vec<M>> {
/// Take the next `size` messages, advancing the cursor; None when exhausted.
fn take_batch_from(state: &mut ParsedState, size: u32) -> Option<Vec<NativeMessage>> {
let cursor = state.cursor;
let messages = messages_of(&mut state.output);
let total = messages.len();
let total = state.output.messages.len();
if cursor >= total {
return None;
}
let end = (cursor + size.max(1) as usize).min(total);
let mut batch = Vec::with_capacity(end - cursor);
for message in &mut messages[cursor..end] {
batch.push(map(std::mem::take(message)));
for message in &mut state.output.messages[cursor..end] {
batch.push(std::mem::take(message));
}
state.cursor = end;
Some(batch)
}
// ==================== WeFlow ====================
// ==================== N-API surface ====================
#[napi(object)]
pub struct NativeWeflowMeta {
pub name: String,
/// "group" | "private" (ChatType enum string values)
pub chat_type: String,
pub group_id: Option<String>,
pub group_avatar: Option<String>,
pub owner_id: Option<String>,
pub message_count: f64,
pub member_count: f64,
pub struct ParseFormatTask {
format: FormatKind,
input: KernelInput,
shared: Arc<Shared>,
}
#[napi(object)]
pub struct NativeParsedMember {
pub platform_id: String,
pub account_name: String,
pub avatar: Option<String>,
}
#[napi(object)]
pub struct NativeParsedMessage {
pub platform_message_id: String,
pub sender_platform_id: String,
pub sender_account_name: String,
/// null when the source `createTime` was JSON null (importer skips those).
pub timestamp: Option<f64>,
/// Numeric MessageType enum value from shared-types.
pub message_type: u32,
pub content: Option<String>,
}
pub struct ParseWeflowTask {
file_path: String,
shared: Arc<Shared<WeflowOutput>>,
}
impl Task for ParseWeflowTask {
impl Task for ParseFormatTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
self.shared
.run_parse(&self.file_path, |buf, path, on_progress| {
weflow::parse_weflow(buf, path, on_progress)
})
self.shared.run_parse(self.format, &self.input)
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
@@ -163,29 +154,39 @@ impl Task for ParseWeflowTask {
}
}
/// Rust WeFlow parser. Usage from JS:
/// `parse()` → `getMeta()` → `takeMembers()` → `takeBatch()` until null.
/// Unified Rust parser for all native-first formats. Usage from JS:
/// `parse()` → `metaJson()` → `takeMembers()` → `takeBatch()` until null →
/// `summaryJson()`. Constructing with an unknown format id throws, which the
/// TS wrapper treats like any other native failure (falls back to TS).
#[napi]
pub struct WeflowParser {
file_path: String,
shared: Arc<Shared<WeflowOutput>>,
pub struct NativeParser {
format: FormatKind,
input: KernelInput,
shared: Arc<Shared>,
}
#[napi]
impl WeflowParser {
impl NativeParser {
#[napi(constructor)]
pub fn new(file_path: String) -> Self {
WeflowParser {
file_path,
pub fn new(format_id: String, file_path: String, options_json: Option<String>) -> Result<Self> {
let format = FormatKind::from_id(&format_id)
.ok_or_else(|| Error::from_reason(format!("unknown native format id: {format_id}")))?;
Ok(NativeParser {
format,
input: KernelInput {
primary_path: file_path,
options_json,
},
shared: Shared::new(),
}
})
}
/// Run the full parse on the libuv thread pool.
#[napi]
pub fn parse(&self) -> AsyncTask<ParseWeflowTask> {
AsyncTask::new(ParseWeflowTask {
file_path: self.file_path.clone(),
pub fn parse(&self) -> AsyncTask<ParseFormatTask> {
AsyncTask::new(ParseFormatTask {
format: self.format,
input: self.input.clone(),
shared: Arc::clone(&self.shared),
})
}
@@ -196,247 +197,47 @@ impl WeflowParser {
self.shared.progress()
}
/// Format-specific meta as a JSON string (shape documented per kernel).
#[napi]
pub fn get_meta(&self) -> Result<NativeWeflowMeta> {
pub fn meta_json(&self) -> Result<String> {
let parsed = self.shared.lock_parsed()?;
let state = parsed
.as_ref()
.ok_or_else(|| Error::from_reason("getMeta() called before parse() completed"))?;
Ok(NativeWeflowMeta {
name: state.output.name.clone(),
chat_type: state.output.chat_type.to_string(),
group_id: state.output.group_id.clone(),
group_avatar: state.output.group_avatar.clone(),
owner_id: state.output.owner_id.clone(),
message_count: state.output.messages.len() as f64,
member_count: state.output.members.len() as f64,
})
}
/// Move members (with avatars) out to JS. Callable once.
#[napi]
pub fn take_members(&self) -> Result<Vec<NativeParsedMember>> {
let mut parsed = self.shared.lock_parsed()?;
let state = parsed
.as_mut()
.ok_or_else(|| Error::from_reason("takeMembers() called before parse() completed"))?;
Ok(std::mem::take(&mut state.output.members)
.into_iter()
.map(|m| NativeParsedMember {
platform_id: m.platform_id,
account_name: m.account_name,
avatar: m.avatar,
})
.collect())
}
/// Move the next batch of messages out to JS; null when exhausted.
#[napi]
pub fn take_batch(&self, size: u32) -> Result<Option<Vec<NativeParsedMessage>>> {
let mut parsed = self.shared.lock_parsed()?;
let state = parsed
.as_mut()
.ok_or_else(|| Error::from_reason("takeBatch() called before parse() completed"))?;
Ok(
take_batch_from(state, |o| &mut o.messages, size, |message| message).map(|batch| {
batch
.into_iter()
.map(|message| {
let MessageOut {
platform_message_id,
sender_platform_id,
sender_account_name,
timestamp,
message_type,
content,
} = message;
NativeParsedMessage {
platform_message_id,
sender_platform_id,
sender_account_name,
timestamp,
message_type,
content,
}
})
.collect()
}),
)
}
}
// ==================== ChatLab JSON ====================
#[napi(object)]
pub struct NativeChatlabMeta {
pub name: String,
/// Passthrough of `meta.type` ("group" when missing), matching the TS parser.
pub chat_type: String,
/// Passthrough of `meta.platform` ("unknown" when missing).
pub platform: String,
pub group_id: Option<String>,
pub group_avatar: Option<String>,
/// true when members came from the top-level `members` array;
/// false when collected from messages (TS emits different object shapes).
pub members_from_head: bool,
pub message_count: f64,
pub member_count: f64,
}
#[napi(object)]
pub struct NativeMemberRole {
pub id: String,
/// Present only when the source role object had a `name` key.
pub name: Option<String>,
}
#[napi(object)]
pub struct NativeChatlabMember {
pub platform_id: String,
pub account_name: String,
pub group_nickname: Option<String>,
pub avatar: Option<String>,
pub roles: Option<Vec<NativeMemberRole>>,
}
#[napi(object)]
#[derive(Default)]
pub struct NativeChatlabMessage {
pub platform_message_id: Option<String>,
pub sender_platform_id: String,
pub sender_account_name: String,
pub sender_group_nickname: Option<String>,
pub timestamp: f64,
/// Numeric MessageType enum value from shared-types.
pub message_type: u32,
/// undefined here means the source `content` was JSON null.
pub content: Option<String>,
pub reply_to_message_id: Option<String>,
}
pub struct ParseChatlabTask {
file_path: String,
shared: Arc<Shared<ChatlabOutput>>,
}
impl Task for ParseChatlabTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
self.shared
.run_parse(&self.file_path, |buf, path, on_progress| {
chatlab::parse_chatlab(buf, path, on_progress)
})
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
/// Rust ChatLab JSON parser. Strict about the format spec: off-spec files
/// error out and the JS side falls back to the pure-TS parser.
/// Usage from JS: `parse()` → `getMeta()` → `takeMembers()` → `takeBatch()` until null.
#[napi]
pub struct ChatlabParser {
file_path: String,
shared: Arc<Shared<ChatlabOutput>>,
}
#[napi]
impl ChatlabParser {
#[napi(constructor)]
pub fn new(file_path: String) -> Self {
ChatlabParser {
file_path,
shared: Shared::new(),
}
}
/// Run the full parse on the libuv thread pool.
#[napi]
pub fn parse(&self) -> AsyncTask<ParseChatlabTask> {
AsyncTask::new(ParseChatlabTask {
file_path: self.file_path.clone(),
shared: Arc::clone(&self.shared),
})
}
/// Poll progress while `parse()` is pending.
#[napi]
pub fn progress(&self) -> NativeParseProgress {
self.shared.progress()
}
#[napi]
pub fn get_meta(&self) -> Result<NativeChatlabMeta> {
let parsed = self.shared.lock_parsed()?;
let state = parsed
.as_ref()
.ok_or_else(|| Error::from_reason("getMeta() called before parse() completed"))?;
Ok(NativeChatlabMeta {
name: state.output.name.clone(),
chat_type: state.output.chat_type.clone(),
platform: state.output.platform.clone(),
group_id: state.output.group_id.clone(),
group_avatar: state.output.group_avatar.clone(),
members_from_head: state.output.members_from_head,
message_count: state.output.messages.len() as f64,
member_count: state.output.members.len() as f64,
})
.ok_or_else(|| Error::from_reason("metaJson() called before parse() completed"))?;
Ok(state.output.meta_json.clone())
}
/// Move members out to JS. Callable once.
#[napi]
pub fn take_members(&self) -> Result<Vec<NativeChatlabMember>> {
pub fn take_members(&self) -> Result<Vec<NativeMember>> {
let mut parsed = self.shared.lock_parsed()?;
let state = parsed
.as_mut()
.ok_or_else(|| Error::from_reason("takeMembers() called before parse() completed"))?;
Ok(std::mem::take(&mut state.output.members)
.into_iter()
.map(|m| NativeChatlabMember {
platform_id: m.platform_id,
account_name: m.account_name,
group_nickname: m.group_nickname,
avatar: m.avatar,
roles: m.roles.map(|roles| {
roles
.into_iter()
.map(|r| NativeMemberRole {
id: r.id,
name: r.name,
})
.collect()
}),
})
.collect())
Ok(std::mem::take(&mut state.output.members))
}
/// Move the next batch of messages out to JS; null when exhausted.
#[napi]
pub fn take_batch(&self, size: u32) -> Result<Option<Vec<NativeChatlabMessage>>> {
pub fn take_batch(&self, size: u32) -> Result<Option<Vec<NativeMessage>>> {
let mut parsed = self.shared.lock_parsed()?;
let state = parsed
.as_mut()
.ok_or_else(|| Error::from_reason("takeBatch() called before parse() completed"))?;
Ok(
take_batch_from(state, |o| &mut o.messages, size, |m| m).map(|batch| {
batch
.into_iter()
.map(|message| NativeChatlabMessage {
platform_message_id: message.platform_message_id,
sender_platform_id: message.sender_platform_id,
sender_account_name: message.sender_account_name,
sender_group_nickname: message.sender_group_nickname,
timestamp: message.timestamp,
message_type: message.message_type,
content: message.content,
reply_to_message_id: message.reply_to_message_id,
})
.collect()
}),
)
Ok(take_batch_from(state, size))
}
/// Post-parse totals as JSON: `{"messageCount":n,"memberCount":n}`.
/// Valid any time after `parse()`, even once the vectors are drained.
#[napi]
pub fn summary_json(&self) -> Result<String> {
let parsed = self.shared.lock_parsed()?;
let state = parsed
.as_ref()
.ok_or_else(|| Error::from_reason("summaryJson() called before parse() completed"))?;
Ok(format!(
"{{\"messageCount\":{},\"memberCount\":{}}}",
state.message_total, state.member_total
))
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Unified kernel output protocol shared by every format kernel.
//!
//! Members and messages use one superset struct each (fields the format does
//! not produce stay `None`); the format-specific meta travels as a JSON string
//! so adding formats never changes the N-API surface.
use napi_derive::napi;
#[napi(object)]
pub struct NativeParseProgress {
pub bytes_read: f64,
pub total_bytes: f64,
pub messages_processed: f64,
}
#[napi(object)]
pub struct NativeMemberRole {
pub id: String,
/// Present only when the source role object had a `name` key.
pub name: Option<String>,
}
#[napi(object)]
pub struct NativeMember {
pub platform_id: String,
pub account_name: String,
pub group_nickname: Option<String>,
pub avatar: Option<String>,
pub roles: Option<Vec<NativeMemberRole>>,
}
#[napi(object)]
#[derive(Default)]
pub struct NativeMessage {
pub platform_message_id: Option<String>,
pub sender_platform_id: String,
pub sender_account_name: String,
pub sender_group_nickname: Option<String>,
/// None when the source timestamp was JSON null (importer skips those).
pub timestamp: Option<f64>,
/// Numeric MessageType enum value from shared-types.
pub message_type: u32,
pub content: Option<String>,
pub reply_to_message_id: Option<String>,
}
/// What a format kernel returns: meta as format-specific JSON plus unified
/// member/message structs, pumped to JS through the shared `NativeParser`.
pub struct KernelOutput {
pub meta_json: String,
pub members: Vec<NativeMember>,
pub messages: Vec<NativeMessage>,
}
+63 -51
View File
@@ -6,36 +6,24 @@
use std::collections::HashMap;
use serde::Serialize;
use serde_json::Value;
use crate::input::KernelInput;
use crate::jsutil::{extract_name_from_file_path, non_empty_str};
use crate::scanner::{for_each_array_element, walk_top_level, ScanResult};
use crate::protocol::{KernelOutput, NativeMember, NativeMessage};
use crate::scanner::{for_each_array_element, walk_top_level, ScanError, ScanResult};
pub struct MemberOut {
pub platform_id: String,
pub account_name: String,
pub avatar: Option<String>,
}
#[derive(Default)]
pub struct MessageOut {
pub platform_message_id: String,
pub sender_platform_id: String,
pub sender_account_name: String,
pub timestamp: Option<f64>,
pub message_type: u32,
pub content: Option<String>,
}
pub struct WeflowOutput {
pub name: String,
/// Shape of `metaJson()` for the weflow kernel (consumed by the TS adapter).
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WeflowMeta {
name: String,
/// "group" | "private" — matches the ChatType enum string values.
pub chat_type: &'static str,
pub group_id: Option<String>,
pub group_avatar: Option<String>,
pub owner_id: Option<String>,
pub members: Vec<MemberOut>,
pub messages: Vec<MessageOut>,
chat_type: &'static str,
group_id: Option<String>,
group_avatar: Option<String>,
owner_id: Option<String>,
}
/// Message type mapping, mirroring `convertMessageType` in weflow.ts.
@@ -86,7 +74,7 @@ fn is_send_equals_one(value: Option<&Value>) -> bool {
}
struct MemberTracker {
order: Vec<MemberOut>,
order: Vec<NativeMember>,
index: HashMap<String, usize>,
}
@@ -110,10 +98,12 @@ impl MemberTracker {
}
None => {
self.index.insert(platform_id.to_string(), self.order.len());
self.order.push(MemberOut {
self.order.push(NativeMember {
platform_id: platform_id.to_string(),
account_name: account_name.to_string(),
group_nickname: None,
avatar: avatar.cloned(),
roles: None,
});
}
}
@@ -122,9 +112,9 @@ impl MemberTracker {
pub fn parse_weflow(
buf: &[u8],
file_path: &str,
input: &KernelInput,
mut on_progress: impl FnMut(u64, u64),
) -> ScanResult<WeflowOutput> {
) -> ScanResult<KernelOutput> {
// Pass 1: structural walk of the top-level object to locate the raw spans
// we care about. `scan_value` skims containers at memchr speed, so this is
// cheap even when `messages` is hundreds of MB.
@@ -186,7 +176,7 @@ pub fn parse_weflow(
.and_then(|s| non_empty_str(s.get("nickname")))
})
.map(|s| s.to_string())
.unwrap_or_else(|| extract_name_from_file_path(file_path, "未知聊天"));
.unwrap_or_else(|| extract_name_from_file_path(&input.primary_path, "未知聊天"));
let group_id: Option<String> = if chat_type == "group" {
session_wxid.map(|s| s.to_string())
@@ -202,7 +192,7 @@ pub fn parse_weflow(
// Pass 2: stream the messages array element by element.
let mut members = MemberTracker::new();
let mut messages: Vec<MessageOut> = Vec::new();
let mut messages: Vec<NativeMessage> = Vec::new();
let mut owner_id: Option<String> = None;
if let Some(raw) = messages_raw {
@@ -283,13 +273,16 @@ pub fn parse_weflow(
}
};
messages.push(MessageOut {
platform_message_id: js_string(obj.get("localId")),
messages.push(NativeMessage {
// WeFlow always emits an id string (String(localId), JS semantics).
platform_message_id: Some(js_string(obj.get("localId"))),
sender_platform_id: sender.to_string(),
sender_account_name: account_name.to_string(),
sender_group_nickname: None,
timestamp,
message_type: convert_message_type(obj.get("type").and_then(|v| v.as_str())),
content,
reply_to_message_id: None,
});
on_progress(end_offset as u64, messages.len() as u64);
@@ -297,12 +290,20 @@ pub fn parse_weflow(
})?;
}
Ok(WeflowOutput {
let meta_json = serde_json::to_string(&WeflowMeta {
name,
chat_type,
group_id,
group_avatar,
owner_id,
})
.map_err(|err| ScanError {
message: format!("meta serialization failed: {err}"),
offset: 0,
})?;
Ok(KernelOutput {
meta_json,
members: members.order,
messages,
})
@@ -312,8 +313,16 @@ pub fn parse_weflow(
mod tests {
use super::*;
fn parse(doc: &str) -> WeflowOutput {
parse_weflow(doc.as_bytes(), "/tmp/测试聊天.json", |_, _| {}).expect("parse should succeed")
fn parse(doc: &str) -> KernelOutput {
let input = KernelInput {
primary_path: "/tmp/测试聊天.json".to_string(),
options_json: None,
};
parse_weflow(doc.as_bytes(), &input, |_, _| {}).expect("parse should succeed")
}
fn meta(out: &KernelOutput) -> Value {
serde_json::from_str(&out.meta_json).expect("meta_json should be valid JSON")
}
#[test]
@@ -330,21 +339,19 @@ mod tests {
]
}"#;
let out = parse(doc);
assert_eq!(out.name, "群显示名");
assert_eq!(out.chat_type, "group");
assert_eq!(out.group_id.as_deref(), Some("room@chatroom"));
let meta = meta(&out);
assert_eq!(meta["name"], "群显示名");
assert_eq!(meta["chatType"], "group");
assert_eq!(meta["groupId"], "room@chatroom");
// session.avatar is empty (falsy) -> falls back to avatars[groupId].
assert_eq!(
out.group_avatar.as_deref(),
Some("data:image/jpeg;base64,ROOM")
);
assert_eq!(out.owner_id.as_deref(), Some("wxid_b"));
assert_eq!(meta["groupAvatar"], "data:image/jpeg;base64,ROOM");
assert_eq!(meta["ownerId"], "wxid_b");
// room@chatroom sender is skipped entirely.
assert_eq!(out.messages.len(), 3);
assert_eq!(out.messages[0].content.as_deref(), Some("hello"));
assert_eq!(out.messages[0].message_type, 0);
assert_eq!(out.messages[0].platform_message_id, "1");
assert_eq!(out.messages[0].platform_message_id.as_deref(), Some("1"));
assert_eq!(out.messages[1].message_type, 1);
assert_eq!(out.messages[2].message_type, 99);
assert_eq!(out.messages[2].content, None);
@@ -382,7 +389,10 @@ mod tests {
// Whitespace-only content is normalized to null.
assert_eq!(out.messages[1].content, None);
// localId missing -> String(undefined).
assert_eq!(out.messages[0].platform_message_id, "undefined");
assert_eq!(
out.messages[0].platform_message_id.as_deref(),
Some("undefined")
);
}
#[test]
@@ -403,13 +413,15 @@ mod tests {
fn private_chat_and_filename_fallback() {
let doc = r#"{"session": {"wxid": "wxid_friend", "type": "其他"}, "messages": []}"#;
let out = parse(doc);
assert_eq!(out.chat_type, "private");
assert_eq!(out.group_id, None);
assert_eq!(out.name, "测试聊天");
let m = meta(&out);
assert_eq!(m["chatType"], "private");
assert_eq!(m["groupId"], Value::Null);
assert_eq!(m["name"], "测试聊天");
let out2 = parse(r#"{"messages": []}"#);
assert_eq!(out2.chat_type, "group");
assert_eq!(out2.name, "测试聊天");
let m2 = meta(&out2);
assert_eq!(m2["chatType"], "group");
assert_eq!(m2["name"], "测试聊天");
}
#[test]
+5
View File
@@ -310,6 +310,11 @@ export { FormatSniffer, createSniffer }
export { getFileSize, formatFileSize, parseTimestamp, isValidYear, createProgress, readFileHeadBytes } from './utils'
// ==================== 导出 native 加载状态(供运行时启动日志使用) ====================
export { getNativeParserStatus } from './native/loader'
export type { NativeParserStatus } from './native/loader'
// ==================== 回调模式 API ====================
/**
+55 -122
View File
@@ -1,41 +1,33 @@
/**
* Rust-accelerated ChatLab JSON parsing with transparent TS fallback.
* ChatLab JSON adapter for the unified native-first wrapper.
*
* Event order and payload shapes are identical to the pure-TS parser in
* formats/chatlab.ts for spec-compliant files (verified by parity tests).
* The Rust kernel is strict about the format spec: off-spec files (wrong
* field types, missing required fields) make parse() fail, and we fall back
* to the TS parser which replicates all JS passthrough quirks. Fallback also
* happens when the native module is unavailable or disabled via
* CHATLAB_DISABLE_NATIVE_PERF=1.
* Payload shapes are identical to the pure-TS parser in formats/chatlab.ts
* for spec-compliant files (verified by parity tests). The Rust kernel is
* strict about the format spec: off-spec files (wrong field types, missing
* required fields) make parse() fail, and the wrapper falls back to the TS
* parser which replicates all JS passthrough quirks.
*/
import type { ChatType, MessageType } from '@openchatlab/shared-types'
import type { ChatlabParser, NativeChatlabMember, NativeChatlabMessage } from '@openchatlab/parser-native'
import type { ParseEvent, ParseOptions, ParsedMember, ParsedMessage, ParsedMeta } from '../types'
import { getFileSize, createProgress } from '../utils'
import { loadNativeParser } from './loader'
import type { NativeMember, NativeMessage } from '@openchatlab/parser-native'
import type { ParsedMember, ParsedMessage, ParsedMeta } from '../types'
import { createNativeFirstParser, type NativeFormatAdapter, type ParseGenerator } from './create-native-parser'
const PROGRESS_POLL_INTERVAL_MS = 200
type ParseGenerator = (options: ParseOptions) => AsyncGenerator<ParseEvent, void, unknown>
function toParsedMessage(message: NativeChatlabMessage): ParsedMessage {
return {
senderPlatformId: message.senderPlatformId,
senderAccountName: message.senderAccountName,
senderGroupNickname: message.senderGroupNickname,
timestamp: message.timestamp,
type: message.messageType as MessageType,
// The kernel rejects a missing content key, so undefined here always
// means the source value was JSON null.
content: message.content ?? null,
platformMessageId: message.platformMessageId,
replyToMessageId: message.replyToMessageId,
}
/** Shape of metaJson() from the Rust chatlab kernel. */
interface ChatlabMetaJson {
name: string
chatType: string
platform: string
groupId: string | null
groupAvatar: string | null
/**
* true when members came from the top-level `members` array;
* false when collected from messages (TS emits different object shapes).
*/
membersFromHead: boolean
}
function toParsedMember(member: NativeChatlabMember, fromHead: boolean): ParsedMember {
function toParsedMember(member: NativeMember, fromHead: boolean): ParsedMember {
if (!fromHead) {
// Members collected from messages carry only these three keys in the TS
// parser; keep the object shape identical.
@@ -55,103 +47,44 @@ function toParsedMember(member: NativeChatlabMember, fromHead: boolean): ParsedM
}
}
async function* parseChatLabNative(
options: ParseOptions,
parser: ChatlabParser,
fallback: ParseGenerator
): AsyncGenerator<ParseEvent, void, unknown> {
const { filePath, batchSize = 5000, onProgress, onLog } = options
const chatlabAdapter: NativeFormatAdapter = {
formatId: 'chatlab',
label: 'ChatLab JSON',
const totalBytes = getFileSize(filePath)
const initialProgress = createProgress('parsing', 0, totalBytes, 0, '')
yield { type: 'progress', data: initialProgress }
onProgress?.(initialProgress)
onLog?.(
'info',
`[NativeParser] Parsing ChatLab JSON with Rust kernel, size: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`
)
mapMeta(metaJson: unknown): ParsedMeta {
const meta = metaJson as ChatlabMetaJson
return {
name: meta.name,
platform: meta.platform,
type: meta.chatType as ChatType,
groupId: meta.groupId ?? undefined,
groupAvatar: meta.groupAvatar ?? undefined,
}
},
const progressTimer = setInterval(() => {
const progress = parser.progress()
onProgress?.(
createProgress(
'parsing',
progress.bytesRead,
totalBytes,
progress.messagesProcessed,
`已处理 ${progress.messagesProcessed} 条消息...`
)
)
}, PROGRESS_POLL_INTERVAL_MS)
mapMembers(members: NativeMember[], metaJson: unknown): ParsedMember[] {
const fromHead = (metaJson as ChatlabMetaJson).membersFromHead
return members.map((member) => toParsedMember(member, fromHead))
},
try {
await parser.parse()
} catch (error) {
onLog?.(
'warn',
`[NativeParser] Rust parse failed, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
// Only progress events have been emitted so far — safe to restart with TS.
yield* fallback(options)
return
} finally {
clearInterval(progressTimer)
}
const nativeMeta = parser.getMeta()
const meta: ParsedMeta = {
name: nativeMeta.name,
platform: nativeMeta.platform,
type: nativeMeta.chatType as ChatType,
groupId: nativeMeta.groupId,
groupAvatar: nativeMeta.groupAvatar,
}
yield { type: 'meta', data: meta }
const members = parser.takeMembers().map((member) => toParsedMember(member, nativeMeta.membersFromHead))
yield { type: 'members', data: members }
let messagesProcessed = 0
while (true) {
const batch = parser.takeBatch(batchSize)
if (!batch) break
messagesProcessed += batch.length
yield { type: 'messages', data: batch.map(toParsedMessage) }
// The consumer typically does synchronous DB writes per batch; yield a
// macrotask so the event loop can flush pending I/O (e.g. SSE progress
// events) instead of being starved until all batches are delivered.
await new Promise((resolve) => setImmediate(resolve))
}
const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '')
yield { type: 'progress', data: doneProgress }
onProgress?.(doneProgress)
onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${members.length} 个成员`)
yield { type: 'done', data: { messageCount: messagesProcessed, memberCount: members.length } }
mapMessage(message: NativeMessage): ParsedMessage {
return {
senderPlatformId: message.senderPlatformId,
senderAccountName: message.senderAccountName,
senderGroupNickname: message.senderGroupNickname,
// The kernel rejects non-number timestamps, so this is always set.
timestamp: message.timestamp as number,
type: message.messageType as MessageType,
// The kernel rejects a missing content key, so undefined here always
// means the source value was JSON null.
content: message.content ?? null,
platformMessageId: message.platformMessageId,
replyToMessageId: message.replyToMessageId,
}
},
}
/** Wrap the TS ChatLab parse generator with native acceleration. */
export function withNativeChatlab(fallback: ParseGenerator): ParseGenerator {
return async function* (options: ParseOptions): AsyncGenerator<ParseEvent, void, unknown> {
const native = loadNativeParser()
if (!native) {
yield* fallback(options)
return
}
let parser: ChatlabParser
try {
parser = new native.ChatlabParser(options.filePath)
} catch (error) {
options.onLog?.(
'warn',
`[NativeParser] Failed to create Rust parser, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
yield* fallback(options)
return
}
yield* parseChatLabNative(options, parser, fallback)
}
return createNativeFirstParser(chatlabAdapter, fallback)
}
@@ -0,0 +1,136 @@
/**
* Unified Rust-accelerated parsing with transparent TS fallback.
*
* One wrapper serves every native-first format: it loads the native module,
* constructs the shared Rust `NativeParser`, polls progress, pumps members
* and message batches, and falls back to the pure-TS parser when the native
* module is unavailable, disabled via CHATLAB_DISABLE_NATIVE_PERF=1, or the
* Rust kernel fails (kernels fail before any data event is emitted, so a TS
* restart is always safe).
*
* Per-format specifics live in a small `NativeFormatAdapter`: the Rust kernel
* id, plus mappers from the unified native structs (and the kernel's meta
* JSON) to ParseEvent payloads. Event order and payload shapes must stay
* identical to the TS parser of the same format (verified by parity tests).
*/
import type { NativeMember, NativeMessage, NativeParser } from '@openchatlab/parser-native'
import type { ParseEvent, ParseOptions, ParsedMember, ParsedMessage, ParsedMeta } from '../types'
import { getFileSize, createProgress } from '../utils'
import { loadNativeParser } from './loader'
const PROGRESS_POLL_INTERVAL_MS = 200
export type ParseGenerator = (options: ParseOptions) => AsyncGenerator<ParseEvent, void, unknown>
export interface NativeFormatAdapter {
/** Kernel id understood by the Rust NativeParser constructor. */
formatId: string
/** Human-readable format label used in import logs. */
label: string
/** Map the kernel's meta JSON (parsed) to the ParsedMeta event payload. */
mapMeta: (metaJson: unknown) => ParsedMeta
/** Map native members to the ParsedMember event payload. */
mapMembers: (members: NativeMember[], metaJson: unknown) => ParsedMember[]
/** Map one native message to the ParsedMessage event payload. */
mapMessage: (message: NativeMessage, metaJson: unknown) => ParsedMessage
}
async function* pumpNativeParser(
adapter: NativeFormatAdapter,
options: ParseOptions,
parser: NativeParser,
fallback: ParseGenerator
): AsyncGenerator<ParseEvent, void, unknown> {
const { filePath, batchSize = 5000, onProgress, onLog } = options
const totalBytes = getFileSize(filePath)
const initialProgress = createProgress('parsing', 0, totalBytes, 0, '')
yield { type: 'progress', data: initialProgress }
onProgress?.(initialProgress)
onLog?.(
'info',
`[NativeParser] Parsing ${adapter.label} with Rust kernel, size: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`
)
const progressTimer = setInterval(() => {
const progress = parser.progress()
onProgress?.(
createProgress(
'parsing',
progress.bytesRead,
totalBytes,
progress.messagesProcessed,
`已处理 ${progress.messagesProcessed} 条消息...`
)
)
}, PROGRESS_POLL_INTERVAL_MS)
try {
await parser.parse()
} catch (error) {
onLog?.(
'warn',
`[NativeParser] Rust parse failed, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
// Only progress events have been emitted so far — safe to restart with TS.
yield* fallback(options)
return
} finally {
clearInterval(progressTimer)
}
const metaJson: unknown = JSON.parse(parser.metaJson())
yield { type: 'meta', data: adapter.mapMeta(metaJson) }
const members = adapter.mapMembers(parser.takeMembers(), metaJson)
yield { type: 'members', data: members }
let messagesProcessed = 0
while (true) {
const batch = parser.takeBatch(batchSize)
if (!batch) break
messagesProcessed += batch.length
yield { type: 'messages', data: batch.map((message) => adapter.mapMessage(message, metaJson)) }
// The consumer typically does synchronous DB writes per batch; yield a
// macrotask so the event loop can flush pending I/O (e.g. SSE progress
// events) instead of being starved until all batches are delivered.
await new Promise((resolve) => setImmediate(resolve))
}
const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '')
yield { type: 'progress', data: doneProgress }
onProgress?.(doneProgress)
onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${members.length} 个成员`)
yield { type: 'done', data: { messageCount: messagesProcessed, memberCount: members.length } }
}
/** Wrap a TS parse generator with native acceleration for the given format. */
export function createNativeFirstParser(adapter: NativeFormatAdapter, fallback: ParseGenerator): ParseGenerator {
return async function* (options: ParseOptions): AsyncGenerator<ParseEvent, void, unknown> {
const native = loadNativeParser()
if (!native) {
yield* fallback(options)
return
}
let parser: NativeParser
try {
parser = new native.NativeParser(
adapter.formatId,
options.filePath,
options.formatOptions ? JSON.stringify(options.formatOptions) : undefined
)
} catch (error) {
options.onLog?.(
'warn',
`[NativeParser] Failed to create Rust parser, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
yield* fallback(options)
return
}
yield* pumpNativeParser(adapter, options, parser, fallback)
}
}
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { mock, test } from 'node:test'
test('treats a loadable native module without NativeParser as unavailable', async () => {
await mock.module('@openchatlab/parser-native', {
exports: {
WeflowParser: class WeflowParser {},
},
})
const { getNativeParserStatus, loadNativeParser } = await import('./loader')
assert.equal(loadNativeParser(), null)
assert.deepEqual(getNativeParserStatus(), {
available: false,
disabled: false,
error: 'Native parser module missing NativeParser export',
})
})
+30 -5
View File
@@ -9,22 +9,39 @@
import { createRequire } from 'node:module'
import type { ChatlabParser, WeflowParser } from '@openchatlab/parser-native'
import type { NativeParser } from '@openchatlab/parser-native'
export interface NativeParserModule {
WeflowParser: typeof WeflowParser
ChatlabParser: typeof ChatlabParser
NativeParser: typeof NativeParser
}
/** Load-state snapshot for startup observability (see node-runtime logNativeParserStatus). */
export interface NativeParserStatus {
available: boolean
/** true when CHATLAB_DISABLE_NATIVE_PERF=1 forces the TS parsers. */
disabled: boolean
/** Load failure reason when unavailable (e.g. module not shipped, ABI mismatch). */
error?: string
}
let cachedModule: NativeParserModule | null | undefined
let cachedLoadError: string | undefined
function isNativeDisabled(): boolean {
return process.env.CHATLAB_DISABLE_NATIVE_PERF === '1'
}
function isNativeParserModule(value: unknown): value is NativeParserModule {
return typeof (value as { NativeParser?: unknown } | null)?.NativeParser === 'function'
}
function requireNativeModule(): NativeParserModule {
const requireFn = createRequire(import.meta.url)
return requireFn('@openchatlab/parser-native') as NativeParserModule
const module = requireFn('@openchatlab/parser-native') as unknown
if (!isNativeParserModule(module)) {
throw new Error('Native parser module missing NativeParser export')
}
return module
}
/**
@@ -37,8 +54,16 @@ export function loadNativeParser(): NativeParserModule | null {
if (cachedModule !== undefined) return cachedModule
try {
cachedModule = requireNativeModule()
} catch {
} catch (error) {
cachedModule = null
cachedLoadError = error instanceof Error ? error.message : String(error)
}
return cachedModule
}
/** Report whether the Rust kernels can load in this process, without throwing. */
export function getNativeParserStatus(): NativeParserStatus {
if (isNativeDisabled()) return { available: false, disabled: true }
const module = loadNativeParser()
return { available: module !== null, disabled: false, error: module ? undefined : cachedLoadError }
}
+50 -123
View File
@@ -1,116 +1,63 @@
/**
* Rust-accelerated WeFlow parsing with transparent TS fallback.
* WeFlow adapter for the unified native-first wrapper.
*
* Event order and payload shapes are identical to the pure-TS parser in
* formats/weflow.ts (verified by parity tests): progress → meta → members →
* message batches → done. Fallback happens when the native module is
* unavailable, disabled via CHATLAB_DISABLE_NATIVE_PERF=1, or fails before
* any data event has been emitted.
* Payload shapes are identical to the pure-TS parser in formats/weflow.ts
* (verified by parity tests). Shared by the ycccccccy-echotrace format module
* (same data structure, same Rust kernel).
*/
import { KNOWN_PLATFORMS, ChatType, type MessageType } from '@openchatlab/shared-types'
import type { NativeParsedMessage, WeflowParser } from '@openchatlab/parser-native'
import type { ParseEvent, ParseOptions, ParsedMessage, ParsedMeta } from '../types'
import { getFileSize, createProgress } from '../utils'
import { loadNativeParser } from './loader'
import type { NativeMember, NativeMessage } from '@openchatlab/parser-native'
import type { ParsedMember, ParsedMessage, ParsedMeta } from '../types'
import { createNativeFirstParser, type NativeFormatAdapter, type ParseGenerator } from './create-native-parser'
const PROGRESS_POLL_INTERVAL_MS = 200
type ParseGenerator = (options: ParseOptions) => AsyncGenerator<ParseEvent, void, unknown>
function toParsedMessage(message: NativeParsedMessage): ParsedMessage {
return {
platformMessageId: message.platformMessageId,
senderPlatformId: message.senderPlatformId,
senderAccountName: message.senderAccountName,
// WeFlow has no separate group nickname field (same as the TS parser).
senderGroupNickname: undefined,
// The TS parser passes createTime through as-is, including null.
timestamp: (message.timestamp ?? null) as unknown as number,
type: message.messageType as MessageType,
content: message.content ?? null,
}
/** Shape of metaJson() from the Rust weflow kernel. */
interface WeflowMetaJson {
name: string
chatType: string
groupId: string | null
groupAvatar: string | null
ownerId: string | null
}
async function* parseWeFlowNative(
options: ParseOptions,
parser: WeflowParser,
fallback: ParseGenerator
): AsyncGenerator<ParseEvent, void, unknown> {
const { filePath, batchSize = 5000, onProgress, onLog } = options
const weflowAdapter: NativeFormatAdapter = {
formatId: 'weflow',
label: 'WeFlow export',
const totalBytes = getFileSize(filePath)
const initialProgress = createProgress('parsing', 0, totalBytes, 0, '')
yield { type: 'progress', data: initialProgress }
onProgress?.(initialProgress)
onLog?.(
'info',
`[NativeParser] Parsing WeFlow export with Rust kernel, size: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`
)
mapMeta(metaJson: unknown): ParsedMeta {
const meta = metaJson as WeflowMetaJson
return {
name: meta.name,
platform: KNOWN_PLATFORMS.WECHAT,
type: meta.chatType === 'private' ? ChatType.PRIVATE : ChatType.GROUP,
groupId: meta.groupId ?? undefined,
groupAvatar: meta.groupAvatar ?? undefined,
ownerId: meta.ownerId ?? undefined,
}
},
const progressTimer = setInterval(() => {
const progress = parser.progress()
onProgress?.(
createProgress(
'parsing',
progress.bytesRead,
totalBytes,
progress.messagesProcessed,
`已处理 ${progress.messagesProcessed} 条消息...`
)
)
}, PROGRESS_POLL_INTERVAL_MS)
mapMembers(members: NativeMember[]): ParsedMember[] {
return members.map((member) => ({
platformId: member.platformId,
accountName: member.accountName,
avatar: member.avatar,
}))
},
try {
await parser.parse()
} catch (error) {
onLog?.(
'warn',
`[NativeParser] Rust parse failed, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
// Only progress events have been emitted so far — safe to restart with TS.
yield* fallback(options)
return
} finally {
clearInterval(progressTimer)
}
const nativeMeta = parser.getMeta()
const meta: ParsedMeta = {
name: nativeMeta.name,
platform: KNOWN_PLATFORMS.WECHAT,
type: nativeMeta.chatType === 'private' ? ChatType.PRIVATE : ChatType.GROUP,
groupId: nativeMeta.groupId,
groupAvatar: nativeMeta.groupAvatar,
ownerId: nativeMeta.ownerId,
}
yield { type: 'meta', data: meta }
const members = parser.takeMembers().map((member) => ({
platformId: member.platformId,
accountName: member.accountName,
avatar: member.avatar,
}))
yield { type: 'members', data: members }
let messagesProcessed = 0
while (true) {
const batch = parser.takeBatch(batchSize)
if (!batch) break
messagesProcessed += batch.length
yield { type: 'messages', data: batch.map(toParsedMessage) }
// The consumer typically does synchronous DB writes per batch; yield a
// macrotask so the event loop can flush pending I/O (e.g. SSE progress
// events) instead of being starved until all batches are delivered.
await new Promise((resolve) => setImmediate(resolve))
}
const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '')
yield { type: 'progress', data: doneProgress }
onProgress?.(doneProgress)
onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${members.length} 个成员`)
yield { type: 'done', data: { messageCount: messagesProcessed, memberCount: members.length } }
mapMessage(message: NativeMessage): ParsedMessage {
return {
// The kernel always emits an id string (String(localId), JS semantics).
platformMessageId: message.platformMessageId,
senderPlatformId: message.senderPlatformId,
senderAccountName: message.senderAccountName,
// WeFlow has no separate group nickname field (same as the TS parser).
senderGroupNickname: undefined,
// The TS parser passes createTime through as-is, including null.
timestamp: (message.timestamp ?? null) as unknown as number,
type: message.messageType as MessageType,
content: message.content ?? null,
}
},
}
/**
@@ -118,25 +65,5 @@ async function* parseWeFlowNative(
* ycccccccy-echotrace format modules (they share the same data structure).
*/
export function withNativeWeflow(fallback: ParseGenerator): ParseGenerator {
return async function* (options: ParseOptions): AsyncGenerator<ParseEvent, void, unknown> {
const native = loadNativeParser()
if (!native) {
yield* fallback(options)
return
}
let parser: WeflowParser
try {
parser = new native.WeflowParser(options.filePath)
} catch (error) {
options.onLog?.(
'warn',
`[NativeParser] Failed to create Rust parser, falling back to TS parser: ${error instanceof Error ? error.message : String(error)}`
)
yield* fallback(options)
return
}
yield* parseWeFlowNative(options, parser, fallback)
}
return createNativeFirstParser(weflowAdapter, fallback)
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Verify the packaged Electron app ships the Rust native parser binary.
*
* Usage: node scripts/check-desktop-native.mjs <platform-arch>
* e.g. node scripts/check-desktop-native.mjs darwin-arm64
* node scripts/check-desktop-native.mjs win32-x64-msvc
*
* Scans apps/desktop/dist for app.asar.unpacked directories and requires
* chatlab-parser-native.<platform-arch>.node to exist (non-empty) inside
* node_modules/@openchatlab/parser-native. Fails fast so a release without
* the native kernel never ships silently.
*/
import { existsSync, readdirSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
const suffix = process.argv[2]
if (!suffix) {
console.error('Usage: node scripts/check-desktop-native.mjs <platform-arch> (e.g. darwin-arm64)')
process.exit(1)
}
const distDir = resolve(import.meta.dirname, '../apps/desktop/dist')
if (!existsSync(distDir)) {
console.error(`[check-desktop-native] dist directory not found: ${distDir}`)
process.exit(1)
}
/** Recursively collect app.asar.unpacked directories (bounded depth). */
function findUnpackedDirs(dir, depth = 0, out = []) {
if (depth > 6) return out
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const full = join(dir, entry.name)
if (entry.name === 'app.asar.unpacked') {
out.push(full)
} else {
findUnpackedDirs(full, depth + 1, out)
}
}
return out
}
const unpackedDirs = findUnpackedDirs(distDir)
if (unpackedDirs.length === 0) {
console.error(`[check-desktop-native] no app.asar.unpacked directory found under ${distDir}`)
process.exit(1)
}
const expectedFile = `chatlab-parser-native.${suffix}.node`
let ok = false
for (const dir of unpackedDirs) {
const nodePath = join(dir, 'node_modules', '@openchatlab', 'parser-native', expectedFile)
if (existsSync(nodePath) && statSync(nodePath).size > 0) {
console.log(`[check-desktop-native] OK: ${nodePath}`)
ok = true
} else {
console.error(`[check-desktop-native] MISSING: ${nodePath}`)
}
}
if (!ok) {
console.error(`[check-desktop-native] packaged app is missing ${expectedFile}; failing the build`)
process.exit(1)
}