From d4d1c2cf4e5b0fc4995a2ad5e8f1c5e4329aeeb3 Mon Sep 17 00:00:00 2001
From: H3CoF6 <1707889225@qq.com>
Date: Thu, 17 Sep 2026 01:22:44 +0800
Subject: [PATCH 1/4] feat(linux): linux adaptation
---
.github/workflows/linux-private-build.yml | 520 ++++++++++++++++++
.github/workflows/release.yml | 15 +-
.gitignore | 3 +-
desktop/package.json | 9 +
desktop/scripts/build-backend.cjs | 35 +-
desktop/scripts/build-linux-installer.cjs | 185 +++++++
desktop/scripts/linux-installer-template.sh | 162 ++++++
.../scripts/linux-native-core-packaging.cjs | 502 +++++++++++++++++
desktop/scripts/native-core-before-pack.cjs | 10 +
desktop/tests/native-core-packaging.test.cjs | 349 +++++++++++-
desktop/tests/package-config.test.cjs | 211 ++++++-
docs/linux-release.md | 72 +++
frontend/nuxt.config.ts | 2 +-
frontend/pages/decrypt.vue | 70 ++-
.../tests/assistant-ui-ssr-external.test.js | 43 ++
frontend/vitest.config.js | 7 +-
pyproject.toml | 2 +-
src/wechat_decrypt_tool/key_service.py | 132 ++++-
src/wechat_decrypt_tool/native_core_broker.py | 76 ++-
src/wechat_decrypt_tool/native_core_client.py | 211 ++++++-
.../native_core_device_credential.py | 67 ++-
src/wechat_decrypt_tool/native_core_lease.py | 1 +
src/wechat_decrypt_tool/platform_support.py | 82 ++-
src/wechat_decrypt_tool/routers/keys.py | 12 +-
src/wechat_decrypt_tool/wcdb_realtime.py | 7 +-
src/wechat_decrypt_tool/wechat_detection.py | 48 +-
tests/test_linux_db_key_flow.py | 98 ++++
tests/test_linux_db_key_frontend.py | 48 ++
.../wx_key-2.0.1-cp310-cp310-win_amd64.whl | Bin 143501 -> 0 bytes
.../wx_key-2.0.1-cp311-cp311-win_amd64.whl | Bin 144142 -> 0 bytes
.../wx_key-2.0.1-cp312-cp312-win_amd64.whl | Bin 144944 -> 0 bytes
.../wx_key-2.0.1-cp313-cp313-win_amd64.whl | Bin 144999 -> 0 bytes
.../wx_key-2.0.1-cp314-cp314-win_amd64.whl | Bin 149379 -> 0 bytes
.../wx_key-2.1.1-cp310-cp310-linux_x86_64.whl | Bin 0 -> 88340 bytes
.../wx_key-2.1.1-cp310-cp310-win_amd64.whl | Bin 0 -> 155031 bytes
.../wx_key-2.1.1-cp311-cp311-linux_x86_64.whl | Bin 0 -> 90302 bytes
.../wx_key-2.1.1-cp311-cp311-win_amd64.whl | Bin 0 -> 156682 bytes
.../wx_key-2.1.1-cp312-cp312-linux_x86_64.whl | Bin 0 -> 91501 bytes
.../wx_key-2.1.1-cp312-cp312-win_amd64.whl | Bin 0 -> 157448 bytes
.../wx_key-2.1.1-cp313-cp313-linux_x86_64.whl | Bin 0 -> 91590 bytes
.../wx_key-2.1.1-cp313-cp313-win_amd64.whl | Bin 0 -> 157470 bytes
.../wx_key-2.1.1-cp314-cp314-linux_x86_64.whl | Bin 0 -> 91549 bytes
.../wx_key-2.1.1-cp314-cp314-win_amd64.whl | Bin 0 -> 162406 bytes
uv.lock | 20 +-
44 files changed, 2890 insertions(+), 109 deletions(-)
create mode 100644 .github/workflows/linux-private-build.yml
create mode 100644 desktop/scripts/build-linux-installer.cjs
create mode 100644 desktop/scripts/linux-installer-template.sh
create mode 100644 desktop/scripts/linux-native-core-packaging.cjs
create mode 100644 docs/linux-release.md
create mode 100644 frontend/tests/assistant-ui-ssr-external.test.js
create mode 100644 tests/test_linux_db_key_flow.py
create mode 100644 tests/test_linux_db_key_frontend.py
delete mode 100644 tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl
delete mode 100644 tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl
delete mode 100644 tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl
delete mode 100644 tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl
delete mode 100644 tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl
create mode 100644 tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl
diff --git a/.github/workflows/linux-private-build.yml b/.github/workflows/linux-private-build.yml
new file mode 100644
index 00000000..fa6331f6
--- /dev/null
+++ b/.github/workflows/linux-private-build.yml
@@ -0,0 +1,520 @@
+name: Linux Private Build
+
+# Linux 的发布构建。与 macOS 的 macos-private-build.yml 对齐,但签名模型不同:
+# Linux 没有代码签名,原生组件的身份 = 内容哈希(linuxIntegrityMode: content-hash-pin)。
+#
+# 整个链路只有两个外部输入:
+# 1. 私藏仓产出的 source-public 原生核心(五元组 pin);
+# 2. 私藏仓 private/wce_integrity 的源码 revision(用于编译导出完整性模块)。
+#
+# 产物形态刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg,
+# Linux 走「用户级、免 root 的 tar.gz + install.sh」。electron-builder 只出 dir 目标。
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ description: Package version; omitted callers derive it from a v* tag
+ required: false
+ type: string
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Package version (for example 2.5.2)
+ required: true
+ type: string
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ build-linux-x64:
+ if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-22.04
+ timeout-minutes: 150
+ env:
+ UV_MANAGED_PYTHON: "true"
+ # The artifact repository identifies the producer; the download repository
+ # identifies where the immutable asset is currently hosted.
+ WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_REPOSITORY }}
+ WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY }}
+ WCE_NATIVE_CORE_ARTIFACT_SHA256: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_SHA256 }}
+ WCE_NATIVE_CORE_ARTIFACT_RUN_ID: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_RUN_ID }}
+ WCE_NATIVE_CORE_SOURCE_REVISION: ${{ vars.WCE_LINUX_NATIVE_CORE_SOURCE_REVISION }}
+ WCE_NATIVE_CORE_BUILD_ID: ${{ vars.WCE_LINUX_NATIVE_CORE_BUILD_ID }}
+ WCE_NATIVE_CORE_CLIENT_SHA256: ${{ vars.WCE_LINUX_NATIVE_CORE_CLIENT_SHA256 }}
+ WCE_NATIVE_CORE_BROKER_SHA256: ${{ vars.WCE_LINUX_NATIVE_CORE_BROKER_SHA256 }}
+ # 可选:完整性模块的源码默认与被 pin 的原生核心同一个仓库/revision
+ # (producer 工作流就在那个 checkout 上跑,所以 private/wce_integrity 也在同一棵树里)。
+ WCE_LINUX_INTEGRITY_SOURCE_REPOSITORY: ${{ vars.WCE_LINUX_INTEGRITY_SOURCE_REPOSITORY }}
+ WCE_LINUX_INTEGRITY_SOURCE_REVISION: ${{ vars.WCE_LINUX_INTEGRITY_SOURCE_REVISION }}
+ WCE_NATIVE_CORE_REQUIRED: "1"
+ WCE_NATIVE_CORE_ALLOW_DEVELOPMENT_ARTIFACTS: "0"
+ CI: "true"
+ PACKAGE_VERSION_INPUT: ${{ inputs.version }}
+ steps:
+ - name: Checkout exact release revision
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Verify immutable source and protected pins
+ shell: bash
+ env:
+ WORKFLOW_REF: ${{ github.ref }}
+ WORKFLOW_REVISION: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ [[ "$WORKFLOW_REVISION" =~ ^[0-9a-f]{40}$ ]]
+ test "$(git rev-parse HEAD)" = "$WORKFLOW_REVISION"
+ test -z "$(git status --porcelain=v1 --untracked-files=all)"
+
+ requested_version="${PACKAGE_VERSION_INPUT#v}"
+ case "$WORKFLOW_REF" in
+ refs/heads/main)
+ test "$(git rev-parse origin/main)" = "$WORKFLOW_REVISION"
+ package_version="$requested_version"
+ ;;
+ refs/tags/v*)
+ tag="${WORKFLOW_REF#refs/tags/}"
+ tag_version="${tag#v}"
+ test "$tag" = "$GITHUB_REF_NAME"
+ test "$(git rev-parse --verify "${WORKFLOW_REF}^{commit}")" = "$WORKFLOW_REVISION"
+ git rev-parse --verify origin/main
+ git merge-base --is-ancestor "$WORKFLOW_REF" origin/main
+ if [[ -n "$requested_version" ]]; then
+ test "$requested_version" = "$tag_version"
+ fi
+ package_version="$tag_version"
+ ;;
+ *)
+ echo "Linux packages may only be built from main or a v* release tag" >&2
+ exit 1
+ ;;
+ esac
+ [[ "$package_version" =~ ^[0-9]+(\.[0-9]+)+([.-][A-Za-z0-9.-]+)?$ ]]
+ printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV"
+
+ [[ "$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]
+ download_repository="${WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY:-}"
+ if [[ -n "$download_repository" ]]; then
+ [[ "$download_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]
+ else
+ download_repository="$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY"
+ fi
+ [[ "$WCE_NATIVE_CORE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]]
+ [[ "$WCE_NATIVE_CORE_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]]
+ [[ ! "$WCE_NATIVE_CORE_ARTIFACT_SHA256" =~ ^0{64}$ ]]
+ [[ "$WCE_NATIVE_CORE_SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]]
+ [[ "$WCE_NATIVE_CORE_BUILD_ID" =~ ^[A-Za-z0-9._-]{8,128}$ ]]
+ [[ ! "$WCE_NATIVE_CORE_BUILD_ID" =~ (^|[._-])(dev|debug|test|local|snapshot|staging)([._-]|$) ]]
+ # 内容哈希 pin 是可选的(缺省时以 manifest 声明为准),但一旦给出就必须是
+ # 非零摘要——发布构建不允许「声明了却不校验」。
+ for name in WCE_NATIVE_CORE_CLIENT_SHA256 WCE_NATIVE_CORE_BROKER_SHA256; do
+ value="${!name:-}"
+ if [[ -n "$value" ]]; then
+ [[ "$value" =~ ^[0-9a-f]{64}$ ]]
+ [[ ! "$value" =~ ^0{64}$ ]]
+ fi
+ done
+
+ integrity_repository="${WCE_LINUX_INTEGRITY_SOURCE_REPOSITORY:-$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY}"
+ integrity_revision="${WCE_LINUX_INTEGRITY_SOURCE_REVISION:-$WCE_NATIVE_CORE_SOURCE_REVISION}"
+ [[ "$integrity_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]
+ [[ "$integrity_revision" =~ ^[0-9a-f]{40}$ ]]
+
+ printf 'LINUX_NATIVE_DOWNLOAD_REPOSITORY=%s\n' "$download_repository" >> "$GITHUB_ENV"
+ printf 'LINUX_INTEGRITY_SOURCE_REPOSITORY=%s\n' "$integrity_repository" >> "$GITHUB_ENV"
+ printf 'LINUX_INTEGRITY_SOURCE_REVISION=%s\n' "$integrity_revision" >> "$GITHUB_ENV"
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "20"
+ cache: npm
+ cache-dependency-path: |
+ frontend/package-lock.json
+ desktop/package-lock.json
+
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version-file: .python-version
+
+ - name: Install build dependencies
+ shell: bash
+ run: |
+ set -euo pipefail
+ python -m pip install uv
+ uv python install 3.11
+ npm ci --prefix frontend
+ npm ci --prefix desktop
+
+ - name: Download the pinned Producer native-core artifact
+ timeout-minutes: 20
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.WCE_LINUX_PRODUCER_READ_TOKEN }}
+ run: |
+ set -euo pipefail
+ test -n "$GH_TOKEN"
+ artifact_dir="$RUNNER_TEMP/wechatdb-native-linux-x64-source-public"
+ release_tag="linux-native-$WCE_NATIVE_CORE_BUILD_ID"
+ release_asset="wechatdb-native-linux-x64-source-public-$WCE_NATIVE_CORE_BUILD_ID.tar.gz"
+ release_archive="$RUNNER_TEMP/$release_asset"
+ downloaded=0
+ for attempt in 1 2 3; do
+ rm -rf "$artifact_dir"
+ mkdir -p "$artifact_dir"
+ rm -f "$release_archive"
+ # 首选不可变 Release 资产(并核对 pin 过的摘要),退路是精确 run id 的
+ # 工作流产物。两条路都必须落在同一份五元组上。
+ if [[ -n "$LINUX_NATIVE_DOWNLOAD_REPOSITORY" ]] && \
+ gh release download "$release_tag" \
+ --repo "$LINUX_NATIVE_DOWNLOAD_REPOSITORY" \
+ --pattern "$release_asset" \
+ --dir "$RUNNER_TEMP"
+ then
+ actual_sha256="$(sha256sum "$release_archive" | awk '{print $1}')"
+ test "$actual_sha256" = "$WCE_NATIVE_CORE_ARTIFACT_SHA256"
+ tar -xzf "$release_archive" -C "$artifact_dir"
+ downloaded=1
+ break
+ elif gh run download "$WCE_NATIVE_CORE_ARTIFACT_RUN_ID" \
+ --repo "$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY" \
+ --name wechatdb-native-linux-x64-source-public \
+ --dir "$artifact_dir"
+ then
+ downloaded=1
+ break
+ fi
+ if [ "$attempt" -lt 3 ]; then
+ sleep "$((attempt * 15))"
+ fi
+ done
+ test "$downloaded" = 1
+
+ - name: Validate the pinned native core against the production policy
+ shell: bash
+ env:
+ WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public
+ run: |
+ set -euo pipefail
+ node -e "require('./desktop/scripts/linux-native-core-packaging.cjs').resolveLinuxNativeCoreArtifacts({ platform: 'linux' })"
+ node -e "
+ const resolved = require('./desktop/scripts/linux-native-core-packaging.cjs')
+ .resolveLinuxNativeCoreArtifacts({ platform: 'linux' });
+ const manifest = resolved.manifest;
+ console.log(JSON.stringify({
+ buildId: manifest.buildId,
+ expiresAtUnix: manifest.buildExpiresAtUnix,
+ clientSha256: manifest.linuxClientSha256,
+ brokerSha256: manifest.linuxBrokerSha256,
+ hostVerification: manifest.linuxHostVerification,
+ sourceRuntime: manifest.sourceRuntime === true,
+ }, null, 2));
+ "
+ printf 'WCE_NATIVE_CORE_ARTIFACT_DIR=%s\n' "$WCE_NATIVE_CORE_ARTIFACT_DIR" >> "$GITHUB_ENV"
+
+ - name: Checkout the pinned private integrity source
+ timeout-minutes: 15
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.WCE_LINUX_PRODUCER_READ_TOKEN }}
+ run: |
+ set -euo pipefail
+ test -n "$GH_TOKEN"
+ # wce_integrity 把 Nuxt 的 CSS 编进导出物里,所以它必须在 UI 构建之后、
+ # 在同一个工作目录里编译(macOS 那份 prebuilt dylib 之所以要记
+ # uiSourceRevision,就是因为这个耦合)。这里改为按 revision 取源码,
+ # 用构建密钥(一次性 P-256 私钥)现编,语义与官方
+ # `-GenerateEphemeralSigningKey` 一致:该密钥只用于导出物自身封签,
+ # 权威封印是原生核心产出的 WES2 sidecar。
+ work="$RUNNER_TEMP/wce-integrity-source"
+ archive="$RUNNER_TEMP/wce-integrity-source.tar.gz"
+ rm -rf "$work"
+ mkdir -p "$work"
+ rm -f "$archive"
+ gh api "repos/$LINUX_INTEGRITY_SOURCE_REPOSITORY/tarball/$LINUX_INTEGRITY_SOURCE_REVISION" > "$archive"
+ test -s "$archive"
+ tar -xzf "$archive" -C "$work"
+ root="$(find "$work" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
+ test -n "$root"
+ source_dir="$root/private/wce_integrity"
+ test -f "$source_dir/Cargo.toml"
+ test -f "$source_dir/build.rs"
+ rm -rf native/wce_integrity
+ mkdir -p native
+ mv "$source_dir" native/wce_integrity
+ rm -rf "$work" "$archive"
+ test -f native/wce_integrity/Cargo.toml
+
+ - name: Install the Rust toolchain
+ uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
+ with:
+ toolchain: stable
+
+ - name: Run focused Python release tests
+ shell: bash
+ env:
+ PYTHONPATH: src
+ run: |
+ set -euo pipefail
+ # 只跑在 Linux 上成立的用例:Windows 专属的原生核心用例(
+ # test_wcdb_realtime_native_core_required / test_native_core_broker_lifecycle)
+ # 依赖 win32 的 PE 与 trust-mode 语义,在这里必然失败,不应作为门禁。
+ uv run pytest -q \
+ tests/test_linux_db_key_flow.py \
+ tests/test_linux_db_key_frontend.py \
+ tests/test_native_core_device_credential.py
+
+ - name: Run focused desktop release tests
+ working-directory: desktop
+ shell: bash
+ run: |
+ set -euo pipefail
+ # 只跑与 Linux 打包契约直接相关的用例。windows-* 那几个与
+ # native-core-runtime(它至今只认 win32/darwin)在 Linux 上必然失败,
+ # 不能当门禁:后者的修复见 docs/linux-release.md 的「已知缺口」。
+ node --test \
+ tests/native-core-packaging.test.cjs \
+ tests/native-core-before-pack.test.cjs \
+ tests/package-config.test.cjs
+
+ - name: Set desktop app version
+ working-directory: desktop
+ shell: bash
+ run: npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version
+
+ - name: Build the Linux package
+ working-directory: desktop
+ shell: bash
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ run: |
+ set -euo pipefail
+ npm run dist:linux
+
+ - name: Verify the packaged Linux runtime
+ working-directory: desktop
+ shell: bash
+ env:
+ WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public
+ run: |
+ set -euo pipefail
+ node - <<'NODE'
+ const childProcess = require("node:child_process");
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const {
+ inspectElf,
+ linuxContentPinErrors,
+ } = require("./scripts/linux-native-core-packaging.cjs");
+
+ const artifactDir = process.env.WCE_NATIVE_CORE_ARTIFACT_DIR;
+ const payload = path.resolve("dist", "linux-unpacked");
+ const backendRoot = path.join(payload, "resources", "backend");
+ const nativeDir = path.join(backendRoot, "native");
+
+ const requireFile = (filePath, { executable = false } = {}) => {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size <= 0) throw new Error(`not a file: ${filePath}`);
+ if (executable && (stat.mode & 0o111) === 0) throw new Error(`not executable: ${filePath}`);
+ return stat;
+ };
+ const digest = (filePath) =>
+ require("node:crypto").createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+
+ // 应用本体与后端可执行文件。
+ requireFile(path.join(payload, "wechat-data-analysis"), { executable: true });
+ requireFile(path.join(backendRoot, "wechat-backend"), { executable: true });
+
+ // 原生三件套必须与 pin 过的产物逐字节一致:打包过程不允许「顺手重编」。
+ for (const name of ["libwechatdb_client.so", "wechatdb_broker", "wechatdb_native_build.json"]) {
+ const packaged = path.join(nativeDir, name);
+ requireFile(packaged);
+ const expected = digest(path.join(artifactDir, name));
+ const actual = digest(packaged);
+ if (actual !== expected) {
+ throw new Error(`packaged ${name} differs from the reviewed native artifact`);
+ }
+ }
+
+ // manifest 声明的内容哈希必须描述打包后的这两个文件本身。
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8")
+ );
+ const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`packaged native core failed its own content pins: ${pinErrors.join("; ")}`);
+ }
+ if (manifest.sourceRuntime !== true || manifest.linuxHostVerification !== "same-user-direct-parent") {
+ throw new Error("packaged native core is not the source-public profile");
+ }
+ if (manifest.buildExpiresAtUnix * 1000 <= Date.now()) {
+ throw new Error("packaged native core build window has already expired");
+ }
+
+ // ELF 身份:客户端/完整性模块是共享对象,broker 是 x86-64 可执行文件。
+ const clientElf = inspectElf(path.join(nativeDir, "libwechatdb_client.so"));
+ if (!clientElf.isSharedObject) throw new Error("packaged native client is not an ELF shared object");
+ const brokerElf = inspectElf(path.join(nativeDir, "wechatdb_broker"));
+ if (!brokerElf.isExecutable) throw new Error("packaged broker is not an ELF executable");
+ const integrity = path.join(nativeDir, "libwce_integrity.so");
+ requireFile(integrity);
+ if (!inspectElf(integrity).isSharedObject) {
+ throw new Error("packaged wce_integrity module is not an ELF shared object");
+ }
+
+ // 一键安装素材:归档里必须有应用本体与原生核心,install.sh 必须内嵌归档摘要。
+ const productName = JSON.parse(fs.readFileSync("package.json", "utf8")).build.productName;
+ const version = JSON.parse(fs.readFileSync("package.json", "utf8")).version;
+ const archiveName = `${productName}-${version}-linux-x86_64.tar.gz`;
+ const archivePath = path.join("dist", archiveName);
+ requireFile(archivePath);
+ const installerPath = path.join("dist", "install.sh");
+ requireFile(installerPath, { executable: true });
+ const installer = fs.readFileSync(installerPath, "utf8");
+ const archiveDigest = digest(archivePath);
+ if (!installer.includes(archiveDigest)) {
+ throw new Error("install.sh does not embed the payload archive digest");
+ }
+ const members = childProcess
+ .execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" })
+ .split("\n")
+ .map((name) => name.replace(/^\.\//, ""))
+ .filter(Boolean);
+ for (const name of [
+ "wechat-data-analysis",
+ "resources/backend/wechat-backend",
+ "resources/backend/native/libwechatdb_client.so",
+ "resources/backend/native/wechatdb_broker",
+ ]) {
+ if (!members.includes(name)) throw new Error(`payload archive is missing ${name}`);
+ }
+ console.log(`verified Linux payload ${archiveName} (${archiveDigest})`);
+ NODE
+
+ - name: Prepare Linux release checksums and provenance
+ working-directory: desktop
+ shell: bash
+ env:
+ WDA_REPOSITORY: ${{ github.repository }}
+ WDA_REVISION: ${{ github.sha }}
+ WDA_TAG: ${{ github.ref_name }}
+ NATIVE_REPOSITORY: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_REPOSITORY }}
+ NATIVE_RUN_ID: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_RUN_ID }}
+ NATIVE_REVISION: ${{ vars.WCE_LINUX_NATIVE_CORE_SOURCE_REVISION }}
+ NATIVE_BUILD_ID: ${{ vars.WCE_LINUX_NATIVE_CORE_BUILD_ID }}
+ NATIVE_ASSET_SHA256: ${{ vars.WCE_LINUX_NATIVE_CORE_ARTIFACT_SHA256 }}
+ WORKFLOW_RUN_ID: ${{ github.run_id }}
+ WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
+ run: |
+ set -euo pipefail
+ cd dist
+ # Windows 那份叫 SHA256SUMS.txt / release-provenance.json;Linux 用带后缀的
+ # 名字,避免两个作业的产物在 merge-multiple 下载时互相覆盖。
+ test -f SHA256SUMS.txt
+ mv SHA256SUMS.txt SHA256SUMS-linux.txt
+ sha256sum -c SHA256SUMS-linux.txt
+
+ node - <<'NODE'
+ const crypto = require("node:crypto");
+ const fs = require("node:fs");
+ const path = require("node:path");
+
+ const digest = (filePath) =>
+ crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+ const positiveInteger = (name) => {
+ const value = Number(process.env[name]);
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ throw new Error(`${name} is not a positive integer`);
+ }
+ return value;
+ };
+
+ const nativeDir = path.resolve(
+ "linux-unpacked",
+ "resources",
+ "backend",
+ "native"
+ );
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8")
+ );
+
+ const assets = fs
+ .readdirSync(".")
+ .filter((name) => name.endsWith("-linux-x86_64.tar.gz") || name === "install.sh")
+ .sort();
+ if (assets.length !== 2) {
+ throw new Error(`expected exactly one payload archive and install.sh: ${assets.join(", ")}`);
+ }
+ const artifacts = assets.map((name) => ({
+ path: name,
+ sha256: digest(name),
+ size: fs.statSync(name).size,
+ }));
+
+ const provenance = {
+ schemaVersion: 1,
+ artifactName: "release-linux-x64",
+ source: {
+ repository: process.env.WDA_REPOSITORY,
+ revision: process.env.WDA_REVISION,
+ tag: process.env.WDA_TAG,
+ },
+ native: {
+ repository: process.env.NATIVE_REPOSITORY,
+ workflowRunId: positiveInteger("NATIVE_RUN_ID"),
+ sourceRevision: process.env.NATIVE_REVISION,
+ buildId: process.env.NATIVE_BUILD_ID,
+ artifactSha256: process.env.NATIVE_ASSET_SHA256,
+ distributionMode: manifest.distributionMode,
+ integrityMode: manifest.linuxIntegrityMode,
+ linuxClientSha256: manifest.linuxClientSha256,
+ linuxBrokerSha256: manifest.linuxBrokerSha256,
+ linuxPeerVerification: manifest.linuxPeerVerification,
+ linuxHostVerification: manifest.linuxHostVerification,
+ sourceRuntime: manifest.sourceRuntime === true,
+ offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits,
+ offlineExportSealFormat: manifest.offlineExportSealFormat,
+ securityNoticeId: manifest.securityNoticeId,
+ securityNoticeSha256: manifest.securityNoticeSha256,
+ securityCheckpointSetId: manifest.securityCheckpointSetId,
+ securityCheckpointCount: manifest.securityCheckpointCount,
+ securityCheckpointSetSha256: manifest.securityCheckpointSetSha256,
+ },
+ integrity: {
+ sourceRepository: process.env.LINUX_INTEGRITY_SOURCE_REPOSITORY,
+ sourceRevision: process.env.LINUX_INTEGRITY_SOURCE_REVISION,
+ binarySha256: digest(path.join(nativeDir, "libwce_integrity.so")),
+ signingKey: "ephemeral-build-key",
+ },
+ build: {
+ workflowRunId: positiveInteger("WORKFLOW_RUN_ID"),
+ workflowRunAttempt: positiveInteger("WORKFLOW_RUN_ATTEMPT"),
+ },
+ artifacts,
+ };
+ fs.writeFileSync(
+ "release-provenance-linux.json",
+ `${JSON.stringify(provenance, null, 2)}\n`
+ );
+ console.log(JSON.stringify(provenance, null, 2));
+ NODE
+
+ - name: Upload Linux release files
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: release-linux-x64
+ if-no-files-found: error
+ retention-days: 14
+ path: |
+ desktop/dist/*-linux-x86_64.tar.gz
+ desktop/dist/install.sh
+ desktop/dist/SHA256SUMS-linux.txt
+ desktop/dist/release-provenance-linux.json
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dad6cfc3..275faa55 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: Release (Windows and macOS ARM64)
+name: Release (Windows, macOS ARM64 and Linux x64)
on:
push:
@@ -730,10 +730,17 @@ jobs:
uses: ./.github/workflows/macos-private-build.yml
secrets: inherit
+ # Linux 与 Windows/macOS 同为必需平台:pin 没配齐就整个 release 失败(fail closed),
+ # 不允许「静默少发一个平台」。准备步骤见 linux-private-build.yml 顶部的注释。
+ build-linux-x64:
+ uses: ./.github/workflows/linux-private-build.yml
+ secrets: inherit
+
publish-release:
needs:
- build-windows
- build-macos-arm64
+ - build-linux-x64
runs-on: ubuntu-latest
steps:
- name: Checkout release history
@@ -804,11 +811,14 @@ jobs:
VER="${VERSION#v}"
EXE="WeChatDataAnalysis-${VER}-Setup.exe"
DMG="WeChatDataAnalysis-${VER}-mac-arm64.dmg"
+ LINUX_TGZ="WeChatDataAnalysis-${VER}-linux-x86_64.tar.gz"
EXE_7Z="${EXE}.7z"
DMG_7Z="${DMG}.7z"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/${VERSION}"
EXE_URL="${BASE_URL}/${EXE}"
MAC_ARM64_URL="${BASE_URL}/${DMG}"
+ LINUX_URL="${BASE_URL}/${LINUX_TGZ}"
+ LINUX_INSTALLER_URL="${BASE_URL}/install.sh"
gh release download "${VERSION}" --pattern "${EXE}" --pattern "${DMG}" --dir /tmp/release
@@ -821,6 +831,8 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "exe_url=${EXE_URL}" >> $GITHUB_OUTPUT
echo "mac_arm64_url=${MAC_ARM64_URL}" >> $GITHUB_OUTPUT
+ echo "linux_url=${LINUX_URL}" >> $GITHUB_OUTPUT
+ echo "linux_installer_url=${LINUX_INSTALLER_URL}" >> $GITHUB_OUTPUT
{ echo "body<> $GITHUB_OUTPUT
MAX_BYTES=209715200
@@ -857,6 +869,7 @@ jobs:
- Windows 安装包 [下载链接](${{ steps.prep.outputs.exe_url }})
- macOS arm64 [下载链接](${{ steps.prep.outputs.mac_arm64_url }})
+ - Linux x64 [下载链接](${{ steps.prep.outputs.linux_url }})(一键安装脚本 [install.sh](${{ steps.prep.outputs.linux_installer_url }}),用户级免 root)
欢迎大家使用和测试~
file_path: ${{ steps.prep.outputs.file_path_list }}
diff --git a/.gitignore b/.gitignore
index 8b42e75a..511c15f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,7 +31,7 @@ wheels/
.ace-tool/
pnpm-lock.yaml
/tools/tmp_isaac64_compare.js
-/native/wce_integrity/
+/native/wce_integrity
/.claude/settings.local.json
.env
.env.*
@@ -87,6 +87,7 @@ pnpm-lock.yaml
/src/wechat_decrypt_tool/native/wechatdb_client.dll
/src/wechat_decrypt_tool/native/wechatdb_broker.exe
/src/wechat_decrypt_tool/native/libwechatdb_client.dylib
+/src/wechat_decrypt_tool/native/libwechatdb_client.so
/src/wechat_decrypt_tool/native/wechatdb_broker
/src/wechat_decrypt_tool/native/wechatdb_native_build.json
/src/wechat_decrypt_tool/native/macos/db-key/
diff --git a/desktop/package.json b/desktop/package.json
index bbcb357a..a0676671 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -18,6 +18,7 @@
"smoke:win:real": "node scripts/smoke-windows-real-database.cjs",
"dist": "npm run dist:win",
"dist:win": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never",
+ "dist:linux": "npm run build:ui && npm run build:backend && electron-builder --linux dir --x64 --publish never && node scripts/build-linux-installer.cjs",
"dist:mac": "npm run dist:mac:arm64",
"dist:mac:arm64": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --mac dmg zip --arm64 --publish never",
"dist:mac:arm64:release": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && cross-env MACOS_DISTRIBUTION_BUILD=1 electron-builder --mac dmg zip --arm64 --publish never --config.forceCodeSigning=true"
@@ -106,6 +107,14 @@
]
}
],
+ "linux": {
+ "icon": "src/icon.png",
+ "category": "Utility",
+ "executableName": "wechat-data-analysis",
+ "target": [
+ "dir"
+ ]
+ },
"win": {
"icon": "build/icon.ico",
"forceCodeSigning": true,
diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs
index de26e1eb..e32a9a48 100644
--- a/desktop/scripts/build-backend.cjs
+++ b/desktop/scripts/build-backend.cjs
@@ -1,4 +1,5 @@
const { aiPackagingArgs, runPackagedAiSmoke } = require('./ai-packaging.cjs');
+const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -11,6 +12,10 @@ const {
macosNativeManifestErrors,
resolveMacosNativeCoreArtifacts,
} = require("./macos-native-core-packaging.cjs");
+const {
+ linuxNativeManifestErrors,
+ resolveLinuxNativeCoreArtifacts,
+} = require("./linux-native-core-packaging.cjs");
const {
resolveIntegrityNativeArtifact,
} = require("./integrity-native-packaging.cjs");
@@ -40,6 +45,8 @@ const NATIVE_CORE_MANIFEST = "wechatdb_native_build.json";
const NATIVE_CORE_ARTIFACTS = Object.freeze({
win32: ["wechatdb_client.dll", "wechatdb_broker.exe", NATIVE_CORE_MANIFEST],
darwin: ["libwechatdb_client.dylib", "wechatdb_broker", NATIVE_CORE_MANIFEST],
+ // Linux 与 macOS 共用同名 broker,客户端是 ELF 共享库;身份靠内容哈希而不是代码签名。
+ linux: ["libwechatdb_client.so", "wechatdb_broker", NATIVE_CORE_MANIFEST],
});
const NATIVE_CORE_FILE_NAMES = new Set(Object.values(NATIVE_CORE_ARTIFACTS).flat());
const LEGACY_WCDB_FILE_NAMES = new Set([
@@ -90,12 +97,15 @@ function nativeCoreManifestErrors(manifest) {
if (!manifest || Array.isArray(manifest) || typeof manifest !== "object") {
return ["manifest must be a JSON object"];
}
- if (!new Set([2, 3]).has(manifest.schemaVersion)) {
- errors.push("schemaVersion must equal 2 or 3");
+ if (!new Set([2, 3, 4]).has(manifest.schemaVersion)) {
+ errors.push("schemaVersion must equal 2, 3 or 4");
}
if (manifest.schemaVersion === 3 && manifest.platform !== "macos") {
errors.push("schemaVersion 3 requires platform macos");
}
+ if (manifest.schemaVersion === 4 && manifest.platform !== "linux") {
+ errors.push("schemaVersion 4 requires platform linux");
+ }
if (manifest.schemaVersion === 2 && Object.prototype.hasOwnProperty.call(manifest, "platform")) {
errors.push("schemaVersion 2 must not declare platform");
}
@@ -145,6 +155,10 @@ function nativeCoreProductionManifestErrors(
if (manifest?.schemaVersion === 3) {
return macosNativeManifestErrors(manifest, { nowUnix });
}
+ // schema v4 是 Linux 的完整契约(含内容哈希 pin 与 45 天窗口),不能走下面 Windows 那套。
+ if (manifest?.schemaVersion === 4) {
+ return linuxNativeManifestErrors(manifest, { nowUnix });
+ }
const errors = nativeCoreManifestErrors(manifest);
const buildIssuedAtUnix = manifest?.buildIssuedAtUnix;
const buildExpiresAtUnix = manifest?.buildExpiresAtUnix;
@@ -271,6 +285,11 @@ function resolveNativeCoreArtifacts({ env = process.env, platform = process.plat
return { ...resolved, allowDevelopment: false, required: true };
}
+ if (platform === "linux" && !allowDevelopment) {
+ const resolved = resolveLinuxNativeCoreArtifacts({ env, platform });
+ return { ...resolved, allowDevelopment: false, required: true };
+ }
+
const artifactDir = path.resolve(explicitValue);
let directoryStat;
try {
@@ -370,6 +389,17 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat
}
const integrityTargetDir = path.join(repoRoot, "native", "wce_integrity", "target", "release");
const fileName = platform === "darwin" ? "libwce_integrity.dylib" : "libwce_integrity.so";
+ // 构建密钥 = 编译 wce_integrity 时注入的 P-256 私钥(WCE_SIGNING_KEY_HEX),只用来给导出物封签,
+ // 公钥随模块一起编译进去,没有任何外部预注册,所以「每次构建现生成一把」是安全的。
+ // 这与 Windows 官方入口 tools/build_wce_integrity.ps1 -GenerateEphemeralSigningKey 语义一致:
+ // 有注入就用注入的(可复现),没注入就现生成一把临时的(Linux/macOS 本地构建的默认)。
+ const providedSigningKey = String(env.WCE_SIGNING_KEY_HEX || "").trim();
+ const signingKeyHex = providedSigningKey || crypto.randomBytes(32).toString("hex");
+ if (!providedSigningKey) {
+ process.stdout.write(
+ `wce_integrity: generated an ephemeral build signing key for ${platform} (set WCE_SIGNING_KEY_HEX to pin it)\n`
+ );
+ }
const result = spawnSync(
"cargo",
["build", "--manifest-path", integrityManifest, "--release"],
@@ -377,6 +407,7 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat
cwd: repoRoot,
env: {
...env,
+ WCE_SIGNING_KEY_HEX: signingKeyHex,
WCE_UI_PUBLIC_DIR: path.join(repoRoot, "frontend", ".output", "public"),
},
stdio: "inherit",
diff --git a/desktop/scripts/build-linux-installer.cjs b/desktop/scripts/build-linux-installer.cjs
new file mode 100644
index 00000000..536edcf3
--- /dev/null
+++ b/desktop/scripts/build-linux-installer.cjs
@@ -0,0 +1,185 @@
+"use strict";
+
+// 把 electron-builder 的 Linux 解包产物(dist/linux-unpacked)打成「一键安装」素材:
+//
+// dist/WeChatDataAnalysis--linux-x86_64.tar.gz 负载
+// dist/install.sh 一键安装/卸载脚本(内嵌负载 SHA-256)
+// dist/SHA256SUMS.txt 给人工核对用
+//
+// 刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg,
+// Linux 走「用户级、免 root 的 tar.gz + install.sh」这条路。
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const desktopRoot = path.resolve(__dirname, "..");
+const DEFAULT_PAYLOAD_DIR = path.join(desktopRoot, "dist", "linux-unpacked");
+const DEFAULT_OUTPUT_DIR = path.join(desktopRoot, "dist");
+const TEMPLATE_PATH = path.join(__dirname, "linux-installer-template.sh");
+const ICON_SOURCE = path.join(desktopRoot, "src", "icon.png");
+const ICON_NAME = "wechat-data-analysis.png";
+const ARCH = "x86_64";
+
+function readPackageMetadata() {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")
+ );
+ const productName = String(packageJson.build?.productName || packageJson.name || "").trim();
+ const version = String(packageJson.version || "").trim();
+ const executableName = String(packageJson.build?.linux?.executableName || "").trim();
+ if (!productName || !version || !executableName) {
+ throw new Error(
+ "package.json must declare build.productName, version and build.linux.executableName"
+ );
+ }
+ return { productName, version, executableName };
+}
+
+function sha256File(filePath) {
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+function runTarCreate(payloadDir, archivePath) {
+ // 用系统 tar 而不是 Node 第三方库:保留权限位/符号链接,且 CI 与本机一致。
+ const result = spawnSync("tar", ["-czf", archivePath, "-C", payloadDir, "."], {
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ if ((result.status ?? 1) !== 0) {
+ throw new Error(`tar failed with exit code ${result.status}`);
+ }
+}
+
+function renderInstallerTemplate({ productName, version, executableName, payloadName, sha256 }) {
+ const template = fs.readFileSync(TEMPLATE_PATH, "utf8");
+ const replacements = {
+ "@@PRODUCT@@": productName,
+ "@@VERSION@@": version,
+ "@@ARCH@@": ARCH,
+ "@@EXECUTABLE@@": executableName,
+ "@@PAYLOAD@@": payloadName,
+ "@@SHA256@@": sha256,
+ };
+ let rendered = template;
+ for (const [token, value] of Object.entries(replacements)) {
+ rendered = rendered.split(token).join(value);
+ }
+ const leftover = rendered.match(/@@[A-Z_]+@@/);
+ if (leftover) throw new Error(`installer template still contains ${leftover[0]}`);
+ return rendered;
+}
+
+function buildLinuxInstaller({
+ payloadDir = DEFAULT_PAYLOAD_DIR,
+ outputDir = DEFAULT_OUTPUT_DIR,
+ metadata = readPackageMetadata(),
+ skipArchive = false,
+} = {}) {
+ const { productName, version, executableName } = metadata;
+ const payloadStat = (() => {
+ try {
+ return fs.statSync(payloadDir);
+ } catch {
+ throw new Error(`Linux payload directory not found: ${payloadDir}`);
+ }
+ })();
+ if (!payloadStat.isDirectory()) {
+ throw new Error(`Linux payload is not a directory: ${payloadDir}`);
+ }
+ const executable = path.join(payloadDir, executableName);
+ try {
+ const stat = fs.statSync(executable);
+ if (!stat.isFile()) throw new Error("not a file");
+ } catch {
+ throw new Error(
+ `Linux payload is missing the application executable: ${executable}. ` +
+ "Run `npm run dist:linux` first."
+ );
+ }
+
+ // 桌面项要用的图标随包一起走,避免安装后引用仓库里的路径。
+ const iconDestination = path.join(payloadDir, "resources", ICON_NAME);
+ fs.mkdirSync(path.dirname(iconDestination), { recursive: true });
+ fs.copyFileSync(ICON_SOURCE, iconDestination);
+
+ fs.mkdirSync(outputDir, { recursive: true });
+ const payloadName = `${productName}-${version}-linux-${ARCH}.tar.gz`;
+ const archivePath = path.join(outputDir, payloadName);
+ if (!skipArchive) {
+ fs.rmSync(archivePath, { force: true });
+ runTarCreate(payloadDir, archivePath);
+ }
+ if (!fs.existsSync(archivePath)) {
+ throw new Error(`Linux payload archive was not produced: ${archivePath}`);
+ }
+ const digest = sha256File(archivePath);
+
+ const installerPath = path.join(outputDir, "install.sh");
+ fs.writeFileSync(
+ installerPath,
+ renderInstallerTemplate({
+ productName,
+ version,
+ executableName,
+ payloadName,
+ sha256: digest,
+ }),
+ { mode: 0o755 }
+ );
+ fs.chmodSync(installerPath, 0o755);
+
+ const checksumsPath = path.join(outputDir, "SHA256SUMS.txt");
+ fs.writeFileSync(
+ checksumsPath,
+ `${digest} ${payloadName}\n${sha256File(installerPath)} install.sh\n`
+ );
+
+ return { archivePath, installerPath, checksumsPath, payloadName, sha256: digest };
+}
+
+function parseCliArguments(argv) {
+ const options = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === "--payload-dir") options.payloadDir = path.resolve(argv[++index]);
+ else if (argument === "--output-dir") options.outputDir = path.resolve(argv[++index]);
+ else if (argument === "--skip-archive") options.skipArchive = true;
+ else if (argument === "--help" || argument === "-h") options.help = true;
+ else throw new Error(`Unknown argument: ${argument}`);
+ }
+ return options;
+}
+
+function main(argv = process.argv.slice(2)) {
+ const options = parseCliArguments(argv);
+ if (options.help) {
+ process.stdout.write(
+ "Usage: node scripts/build-linux-installer.cjs [--payload-dir DIR] [--output-dir DIR] [--skip-archive]\n"
+ );
+ return 0;
+ }
+ const result = buildLinuxInstaller(options);
+ process.stdout.write(`Linux payload: ${result.archivePath}\n`);
+ process.stdout.write(`Installer: ${result.installerPath}\n`);
+ process.stdout.write(`SHA-256: ${result.sha256}\n`);
+ return 0;
+}
+
+if (require.main === module) {
+ try {
+ process.exitCode = main();
+ } catch (error) {
+ process.stderr.write(`${error?.message || error}\n`);
+ process.exitCode = 1;
+ }
+}
+
+module.exports = {
+ ARCH,
+ buildLinuxInstaller,
+ parseCliArguments,
+ readPackageMetadata,
+ renderInstallerTemplate,
+};
diff --git a/desktop/scripts/linux-installer-template.sh b/desktop/scripts/linux-installer-template.sh
new file mode 100644
index 00000000..9f8eee69
--- /dev/null
+++ b/desktop/scripts/linux-installer-template.sh
@@ -0,0 +1,162 @@
+#!/bin/sh
+# @@PRODUCT@@ @@VERSION@@ (linux-@@ARCH@@) 一键安装脚本 —— 由 Build-LinuxInstaller 生成,请勿手改。
+#
+# 设计取舍(Linux 没有安装包是刻意的):
+# * 不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg。
+# * 所以这里给一个「用户级、免 root」的安装脚本:解包到用户目录 + 桌面项 + 启动器。
+# * 产物身份靠内容哈希:脚本里内嵌 tarball 的 SHA-256,装之前先校验。
+#
+# 用法:
+# ./install.sh # 装到 ${XDG_DATA_HOME:-~/.local/share}/wechat-data-analysis
+# ./install.sh --prefix /opt/x # 自定义前缀
+# ./install.sh --uninstall # 卸载
+set -eu
+
+PRODUCT='@@PRODUCT@@'
+VERSION='@@VERSION@@'
+ARCH='@@ARCH@@'
+PAYLOAD_NAME='@@PAYLOAD@@'
+PAYLOAD_SHA256='@@SHA256@@'
+
+DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
+BIN_HOME="${XDG_BIN_HOME:-$HOME/.local/bin}"
+DEFAULT_PREFIX="$DATA_HOME/wechat-data-analysis"
+PREFIX="$DEFAULT_PREFIX"
+UNINSTALL=0
+
+die() { printf '错误: %s\n' "$1" >&2; exit 1; }
+info() { printf '%s\n' "$1"; }
+
+usage() {
+ cat </dev/null 2>&1; then
+ actual=$(sha256sum "$PAYLOAD" | awk '{print $1}')
+ elif command -v shasum >/dev/null 2>&1; then
+ actual=$(shasum -a 256 "$PAYLOAD" | awk '{print $1}')
+ else
+ die "找不到 sha256sum 或 shasum,无法校验安装包完整性"
+ fi
+ [ "$actual" = "$PAYLOAD_SHA256" ] || die "安装包校验失败:期望 $PAYLOAD_SHA256,实际 $actual"
+}
+
+verify_hash
+info "校验通过: $PAYLOAD_NAME"
+
+TARGET="$PREFIX/$VERSION"
+[ "$TARGET" != "$PREFIX" ] || die "安装目标解析异常: $TARGET"
+
+mkdir -p "$PREFIX" "$BIN_HOME"
+STAGING="$PREFIX/.staging-$$"
+rm -rf "$STAGING"
+mkdir -p "$STAGING"
+
+cleanup() { rm -rf "$STAGING"; }
+trap cleanup EXIT HUP INT TERM
+
+info "解包到 $TARGET ..."
+tar -xzf "$PAYLOAD" -C "$STAGING" || die "解包失败"
+[ -x "$STAGING/@@EXECUTABLE@@" ] || die "安装包里找不到可执行文件 @@EXECUTABLE@@"
+
+rm -rf "$TARGET"
+# staging 与 TARGET 同处 $PREFIX 下,rename 是原子的:不会留下半新半旧的目录。
+mv "$STAGING" "$TARGET"
+cleanup
+trap - EXIT HUP INT TERM
+
+# current 是原子切换的指针,升级时不会留下半新半旧的目录。
+ln -sfn "$TARGET" "$PREFIX/current"
+
+LAUNCHER="$PREFIX/bin/wechat-data-analysis"
+mkdir -p "$PREFIX/bin"
+cat > "$LAUNCHER" < "$DESKTOP_FILE"
+chmod 0644 "$DESKTOP_FILE"
+
+info "已安装: $TARGET"
+info "启动器: $LAUNCHER"
+info "桌面项: $DESKTOP_FILE"
+case ":$PATH:" in
+ *":$BIN_HOME:"*) info "命令行可用: wechat-data-analysis" ;;
+ *) info "提示: 把 $BIN_HOME 加进 PATH 后可直接用 wechat-data-analysis" ;;
+esac
+
+# Electron 在 Linux 上依赖「非特权用户命名空间」来开沙箱;内核关掉它时应用会起不来。
+# 这里只做提示,不替用户改内核参数,也不默认加 --no-sandbox(那会削弱沙箱)。
+if [ -r /proc/sys/user/max_user_namespaces ] && [ "$(cat /proc/sys/user/max_user_namespaces)" = "0" ]; then
+ info "警告: 当前内核禁用了非特权用户命名空间,Electron 沙箱无法启动。"
+ info " 可用 sysctl user.max_user_namespaces=10000 打开,或自行以 --no-sandbox 运行(不推荐)。"
+fi
+
+info "卸载: $SCRIPT_DIR/install.sh --uninstall --prefix $PREFIX"
diff --git a/desktop/scripts/linux-native-core-packaging.cjs b/desktop/scripts/linux-native-core-packaging.cjs
new file mode 100644
index 00000000..9e2604c3
--- /dev/null
+++ b/desktop/scripts/linux-native-core-packaging.cjs
@@ -0,0 +1,502 @@
+"use strict";
+
+// Linux 的 native core 消费校验。
+//
+// 与 macOS 那套(macos-native-core-packaging.cjs)对齐,但签名模型完全不同:
+// Linux 没有代码签名,产物身份 = **内容哈希**(linuxIntegrityMode: content-hash-pin)。
+// 所以这里把 manifest 里的 linuxClientSha256 / linuxBrokerSha256 当成身份声明,
+// 逐字节比对实际文件,再用 SHA256SUMS.txt + provenance.json 把来源钉到某个 WCDB revision。
+// 一旦内容被替换,哈希必然对不上,直接 fail closed。
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const CLIENT_NAME = "libwechatdb_client.so";
+const BROKER_NAME = "wechatdb_broker";
+const MANIFEST_NAME = "wechatdb_native_build.json";
+const CHECKSUMS_NAME = "SHA256SUMS.txt";
+const PROVENANCE_NAME = "provenance.json";
+const ARTIFACT_TEST_NAME = "Test-LinuxNativeProductionArtifact.py";
+
+const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60;
+const SHA256_PATTERN = /^[0-9a-f]{64}$/;
+const BUILD_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/;
+const REVISION_PATTERN = /^[0-9a-f]{40}$/;
+const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+const NON_PRODUCTION_BUILD_ID_PATTERN =
+ /(^|[._-])(dev|debug|test|local|snapshot|staging)([._-]|$)/i;
+
+const INTEGRITY_MODE = "content-hash-pin";
+// 产出这份产物的工作流路径。它也是身份的一部分:只有被审阅过的 producer 才允许
+// 产出发布路径会接受的产物(artifact 内部的 Python 校验器断言同一个常量)。
+const PRODUCER_WORKFLOW = ".github/workflows/linux-native-production.yml";
+const PEER_VERIFICATION = "same-user-peer-credentials";
+const HOST_VERIFICATION = Object.freeze({
+ production: "content-hash-pin",
+ sourceRuntime: "same-user-direct-parent",
+});
+const ARTIFACT_NAME_PATTERN = /^wechatdb-native-linux-x64-(production|source-public)$/;
+const PRODUCERS = new Set(["github-actions", "manual"]);
+
+// 校验集只覆盖「运行时真正要用的四个文件」;SHA256SUMS.txt / provenance.json 是自证材料。
+const CHECKSUM_FILE_NAMES = Object.freeze([
+ ARTIFACT_TEST_NAME,
+ CLIENT_NAME,
+ BROKER_NAME,
+ MANIFEST_NAME,
+]);
+const ARTIFACT_FILE_NAMES = Object.freeze([
+ ...CHECKSUM_FILE_NAMES,
+ CHECKSUMS_NAME,
+ PROVENANCE_NAME,
+]);
+const RUNTIME_FILE_NAMES = Object.freeze([CLIENT_NAME, BROKER_NAME, MANIFEST_NAME]);
+
+const MANIFEST_REQUIRED_FIELDS = Object.freeze([
+ "schemaVersion",
+ "platform",
+ "distributionMode",
+ "buildId",
+ "buildIssuedAtUnix",
+ "buildExpiresAtUnix",
+ "developmentBuild",
+ "offlineBootstrapFeatureBits",
+ "offlineExportSealFormat",
+ "codeSignatureEnforced",
+ "rootPublicKeyCompiled",
+ "testHooksEnabled",
+ "stagingPinnedSignerTrust",
+ "linuxIntegrityMode",
+ "linuxClientSha256",
+ "linuxBrokerSha256",
+ "linuxPeerVerification",
+ "linuxHostVerification",
+ "securityNoticeId",
+ "securityNoticeSha256",
+ "securityCheckpointSetId",
+ "securityCheckpointCount",
+ "securityCheckpointSetSha256",
+]);
+const MANIFEST_OPTIONAL_FIELDS = Object.freeze(["sourceRuntime"]);
+const PROVENANCE_FIELDS = Object.freeze([
+ "schemaVersion",
+ "artifactName",
+ "producer",
+ "workflow",
+ "repository",
+ "runId",
+ "runAttempt",
+ "sourceRevision",
+ "build",
+ "manifestSha256",
+ "checksumsSha256",
+ "artifacts",
+]);
+
+function exactKeys(value, required, optional = []) {
+ if (!value || Array.isArray(value) || typeof value !== "object") return false;
+ const allowed = new Set([...required, ...optional]);
+ const actual = Object.keys(value);
+ if (actual.some((name) => !allowed.has(name))) return false;
+ return required.every((name) => Object.prototype.hasOwnProperty.call(value, name));
+}
+
+function sha256File(filePath) {
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+function isNonZeroSha256(value) {
+ const text = String(value || "");
+ return SHA256_PATTERN.test(text) && !/^0{64}$/.test(text);
+}
+
+function readJson(filePath, label, maximum = 64 * 1024) {
+ try {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size <= 0 || stat.size > maximum) throw new Error("invalid size");
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ if (!value || Array.isArray(value) || typeof value !== "object") {
+ throw new Error("root must be an object");
+ }
+ return value;
+ } catch (error) {
+ throw new Error(`Invalid ${label} at ${filePath}: ${error.message}`);
+ }
+}
+
+function requiredEnv(env, name, pattern) {
+ const value = String(env[name] || "").trim();
+ if (!value || (pattern && !pattern.test(value))) {
+ throw new Error(`Missing or invalid ${name}`);
+ }
+ return value;
+}
+
+function optionalEnvPin(env, name) {
+ const value = String(env[name] || "").trim();
+ if (!value) return null;
+ if (!isNonZeroSha256(value)) {
+ throw new Error(`${name} must be a non-zero lowercase SHA-256 digest`);
+ }
+ return value;
+}
+
+function parseChecksums(filePath) {
+ const records = new Map();
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
+ for (const line of lines) {
+ const match = /^([0-9a-f]{64}) {2}([A-Za-z0-9._-]+)$/.exec(line);
+ if (!match || records.has(match[2])) throw new Error("SHA256SUMS.txt has an invalid record");
+ records.set(match[2], match[1]);
+ }
+ return records;
+}
+
+function linuxNativeManifestErrors(manifest, { nowUnix = Math.floor(Date.now() / 1000) } = {}) {
+ const errors = [];
+ if (!exactKeys(manifest, MANIFEST_REQUIRED_FIELDS, MANIFEST_OPTIONAL_FIELDS)) {
+ errors.push("manifest fields must match Linux schema v4 exactly");
+ return errors;
+ }
+ if (manifest.schemaVersion !== 4) errors.push("schemaVersion must equal 4");
+ if (manifest.platform !== "linux") errors.push("platform must equal linux");
+ if (manifest.distributionMode !== "public") errors.push("distributionMode must equal public");
+ if (
+ !BUILD_ID_PATTERN.test(String(manifest.buildId || "")) ||
+ NON_PRODUCTION_BUILD_ID_PATTERN.test(String(manifest.buildId || ""))
+ ) {
+ errors.push("buildId must be an immutable production identity");
+ }
+ const issued = manifest.buildIssuedAtUnix;
+ const expires = manifest.buildExpiresAtUnix;
+ if (
+ !Number.isSafeInteger(issued) ||
+ issued <= 0 ||
+ !Number.isSafeInteger(expires) ||
+ expires !== issued + BUILD_LIFETIME_SECONDS
+ ) {
+ errors.push("build validity window must equal exactly 45 days");
+ } else if (!Number.isSafeInteger(nowUnix) || nowUnix < 0 || nowUnix >= expires) {
+ errors.push("build has reached its fixed expiration time");
+ }
+ if (
+ manifest.developmentBuild !== false ||
+ manifest.offlineBootstrapFeatureBits !== 3 ||
+ manifest.offlineExportSealFormat !== "WES2" ||
+ manifest.codeSignatureEnforced !== true ||
+ manifest.rootPublicKeyCompiled !== true ||
+ manifest.testHooksEnabled !== false ||
+ manifest.stagingPinnedSignerTrust !== false
+ ) {
+ errors.push("native production security fields do not match policy");
+ }
+ if (manifest.linuxIntegrityMode !== INTEGRITY_MODE) {
+ errors.push(`linuxIntegrityMode must equal ${INTEGRITY_MODE}`);
+ }
+ if (manifest.linuxPeerVerification !== PEER_VERIFICATION) {
+ errors.push(`linuxPeerVerification must equal ${PEER_VERIFICATION}`);
+ }
+ // 两个 profile 的 host 校验强度不同,必须自洽:源码分发用「直接父进程」,
+ // 否则用「内容哈希 pin」。声明 sourceRuntime 就只能是前者。
+ const sourceRuntime = manifest.sourceRuntime === true;
+ if (
+ Object.prototype.hasOwnProperty.call(manifest, "sourceRuntime") &&
+ manifest.sourceRuntime !== true
+ ) {
+ errors.push("sourceRuntime must be true when present");
+ }
+ const expectedHostVerification = sourceRuntime
+ ? HOST_VERIFICATION.sourceRuntime
+ : HOST_VERIFICATION.production;
+ if (manifest.linuxHostVerification !== expectedHostVerification) {
+ errors.push(`linuxHostVerification must equal ${expectedHostVerification}`);
+ }
+ const pins = [manifest.linuxClientSha256, manifest.linuxBrokerSha256];
+ if (pins.some((value) => !isNonZeroSha256(value))) {
+ errors.push("linux client and broker content pins must be non-zero SHA-256 digests");
+ } else if (pins[0] === pins[1]) {
+ errors.push("linux client and broker content pins must be distinct");
+ }
+ if (
+ manifest.securityNoticeId !== "WCE-AUTOMATED-ANALYSIS-NOTICE-V2" ||
+ !SHA256_PATTERN.test(String(manifest.securityNoticeSha256 || "")) ||
+ manifest.securityCheckpointSetId !== "WCE-AI-CHECKPOINT-SET-V3" ||
+ manifest.securityCheckpointCount !== 7 ||
+ !SHA256_PATTERN.test(String(manifest.securityCheckpointSetSha256 || ""))
+ ) {
+ errors.push("native security checkpoint contract mismatch");
+ }
+ return errors;
+}
+
+// 内容哈希就是 Linux 的身份。manifest 声明什么,盘上就必须是什么。
+function linuxContentPinErrors({ directory, manifest }) {
+ const errors = [];
+ const expectations = [
+ [CLIENT_NAME, manifest?.linuxClientSha256],
+ [BROKER_NAME, manifest?.linuxBrokerSha256],
+ ];
+ for (const [name, expected] of expectations) {
+ const filePath = path.join(directory, name);
+ try {
+ if (!fs.statSync(filePath).isFile()) throw new Error("not a regular file");
+ } catch {
+ errors.push(`missing native component ${name}`);
+ continue;
+ }
+ const actual = sha256File(filePath);
+ if (actual !== expected) {
+ errors.push(`content hash mismatch for ${name}: expected ${expected}, received ${actual}`);
+ }
+ }
+ return errors;
+}
+
+// 极简 ELF 头解析:不依赖 readelf/file,Linux 与 macOS 主机上都能跑。
+// 只断言「身份声明」需要的部分:64 位、小端、x86-64、以及可执行类别。
+function inspectElf(filePath) {
+ const header = Buffer.alloc(20);
+ const handle = fs.openSync(filePath, "r");
+ try {
+ fs.readSync(handle, header, 0, 20, 0);
+ } finally {
+ fs.closeSync(handle);
+ }
+ if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
+ throw new Error(`not an ELF file: ${filePath}`);
+ }
+ const elfClass = header[4];
+ const dataEncoding = header[5];
+ if (elfClass !== 2) throw new Error(`ELF is not 64-bit: ${filePath}`);
+ if (dataEncoding !== 1) throw new Error(`ELF is not little-endian: ${filePath}`);
+ const type = header.readUInt16LE(16);
+ const machine = header.readUInt16LE(18);
+ if (machine !== 0x3e) throw new Error(`ELF is not x86-64: ${filePath}`);
+ return { type, machine, isSharedObject: type === 3, isExecutable: type === 2 || type === 3 };
+}
+
+function resolveLinuxNativeCoreArtifacts({
+ env = process.env,
+ platform = process.platform,
+ nowUnix = Math.floor(Date.now() / 1000),
+} = {}) {
+ if (platform !== "linux") {
+ throw new Error(`Linux native-core artifacts cannot be resolved on platform: ${platform}`);
+ }
+ const artifactDirValue = String(env.WCE_NATIVE_CORE_ARTIFACT_DIR || "").trim();
+ if (!artifactDirValue) {
+ throw new Error(
+ "Missing WCE_NATIVE_CORE_ARTIFACT_DIR. Expected a directory containing: " +
+ RUNTIME_FILE_NAMES.join(", ")
+ );
+ }
+ const artifactDir = path.resolve(artifactDirValue);
+ let stat;
+ try {
+ stat = fs.statSync(artifactDir);
+ } catch {
+ throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not readable: ${artifactDir}`);
+ }
+ if (!stat.isDirectory()) {
+ throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not a directory: ${artifactDir}`);
+ }
+ const entries = fs.readdirSync(artifactDir, { withFileTypes: true });
+ const files = entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .sort();
+ const wanted = [...ARTIFACT_FILE_NAMES].sort();
+ if (entries.some((entry) => !entry.isFile()) || files.join("\n") !== wanted.join("\n")) {
+ throw new Error(
+ `Linux native-core artifact allowlist mismatch. Expected ${wanted.join(", ")}, received ${files.join(", ")}`
+ );
+ }
+
+ const repository = requiredEnv(env, "WCE_NATIVE_CORE_ARTIFACT_REPOSITORY", REPOSITORY_PATTERN);
+ const sourceRevision = requiredEnv(env, "WCE_NATIVE_CORE_SOURCE_REVISION", REVISION_PATTERN);
+ const buildId = requiredEnv(env, "WCE_NATIVE_CORE_BUILD_ID", BUILD_ID_PATTERN);
+ const clientPin = optionalEnvPin(env, "WCE_NATIVE_CORE_CLIENT_SHA256");
+ const brokerPin = optionalEnvPin(env, "WCE_NATIVE_CORE_BROKER_SHA256");
+
+ const manifestPath = path.join(artifactDir, MANIFEST_NAME);
+ const manifest = readJson(manifestPath, "Linux native-core manifest", 16 * 1024);
+ const manifestErrors = linuxNativeManifestErrors(manifest, { nowUnix });
+ if (manifestErrors.length > 0) {
+ throw new Error(`Refusing Linux native-core artifact: ${manifestErrors.join("; ")}`);
+ }
+ if (manifest.buildId !== buildId) {
+ throw new Error("Linux native-core manifest does not match the protected build id pin");
+ }
+ if (clientPin && manifest.linuxClientSha256 !== clientPin) {
+ throw new Error("Linux native-core manifest does not match the protected client content pin");
+ }
+ if (brokerPin && manifest.linuxBrokerSha256 !== brokerPin) {
+ throw new Error("Linux native-core manifest does not match the protected broker content pin");
+ }
+
+ const checksumsPath = path.join(artifactDir, CHECKSUMS_NAME);
+ const checksums = parseChecksums(checksumsPath);
+ if (
+ checksums.size !== CHECKSUM_FILE_NAMES.length ||
+ CHECKSUM_FILE_NAMES.some(
+ (name) => checksums.get(name) !== sha256File(path.join(artifactDir, name))
+ )
+ ) {
+ throw new Error("Linux native-core checksum set does not match the artifact allowlist");
+ }
+
+ const provenance = readJson(path.join(artifactDir, PROVENANCE_NAME), "Linux native-core provenance");
+ if (!exactKeys(provenance, PROVENANCE_FIELDS)) {
+ throw new Error("Linux native-core provenance fields do not match schema v1 exactly");
+ }
+ const producer = String(provenance.producer || "");
+ if (!PRODUCERS.has(producer)) {
+ throw new Error("Linux native-core provenance must come from github-actions or a manual producer");
+ }
+ if (provenance.schemaVersion !== 1) {
+ throw new Error("Linux native-core provenance schemaVersion must equal 1");
+ }
+ if (!ARTIFACT_NAME_PATTERN.test(String(provenance.artifactName || ""))) {
+ throw new Error("Linux native-core provenance artifactName is not a Linux x64 profile");
+ }
+ if (provenance.repository !== repository) {
+ throw new Error("Linux native-core provenance repository does not match the protected pin");
+ }
+ if (provenance.sourceRevision !== sourceRevision) {
+ throw new Error("Linux native-core provenance revision does not match the protected pin");
+ }
+ const expectedRunId = String(env.WCE_NATIVE_CORE_ARTIFACT_RUN_ID || "").trim();
+ if (producer === "github-actions") {
+ if (!/^[1-9][0-9]*$/.test(expectedRunId) || Number(provenance.runId) !== Number(expectedRunId)) {
+ throw new Error("Linux native-core provenance run id does not match the protected pin");
+ }
+ if (!Number.isSafeInteger(provenance.runAttempt) || provenance.runAttempt <= 0) {
+ throw new Error("Linux native-core provenance runAttempt must be a positive integer");
+ }
+ if (provenance.workflow !== PRODUCER_WORKFLOW) {
+ throw new Error(
+ `Linux native-core provenance must come from ${PRODUCER_WORKFLOW}`
+ );
+ }
+ } else {
+ if (expectedRunId !== "" || provenance.runId !== 0 || provenance.runAttempt !== 0) {
+ throw new Error("Manual Linux native-core provenance must not claim a CI run");
+ }
+ if (String(provenance.workflow || "") !== "manual") {
+ throw new Error("Manual Linux native-core provenance must declare workflow manual");
+ }
+ }
+ if (provenance.manifestSha256 !== sha256File(manifestPath)) {
+ throw new Error("Linux native-core provenance manifest hash mismatch");
+ }
+ if (provenance.checksumsSha256 !== sha256File(checksumsPath)) {
+ throw new Error("Linux native-core provenance checksums hash mismatch");
+ }
+ const expectedInventory = CHECKSUM_FILE_NAMES.map((name) => ({
+ path: name,
+ sha256: sha256File(path.join(artifactDir, name)),
+ size: fs.statSync(path.join(artifactDir, name)).size,
+ }));
+ if (JSON.stringify(provenance.artifacts) !== JSON.stringify(expectedInventory)) {
+ throw new Error("Linux native-core provenance artifact inventory mismatch");
+ }
+ const build = provenance.build;
+ // linuxHostVerification / sourceRuntime 只出现在源码分发(-sp)那份 provenance 里,
+ // 所以它们是「可选的,但出现就必须与 manifest 一致」。
+ if (
+ !exactKeys(
+ build,
+ [
+ "architecture",
+ "distributionMode",
+ "expiresAtUnix",
+ "id",
+ "integrityMode",
+ "issuedAtUnix",
+ "linuxBrokerSha256",
+ "linuxClientSha256",
+ "offlineBootstrapFeatureBits",
+ "offlineExportSealFormat",
+ "platform",
+ "readOnlyBuild",
+ "securityCheckpointCount",
+ "securityCheckpointSetId",
+ "securityCheckpointSetSha256",
+ "securityNoticeId",
+ "securityNoticeSha256",
+ ],
+ ["linuxHostVerification", "sourceRuntime"]
+ ) ||
+ build.id !== manifest.buildId ||
+ build.platform !== "linux" ||
+ build.architecture !== "x64" ||
+ build.distributionMode !== manifest.distributionMode ||
+ build.integrityMode !== manifest.linuxIntegrityMode ||
+ build.readOnlyBuild !== true ||
+ build.issuedAtUnix !== manifest.buildIssuedAtUnix ||
+ build.expiresAtUnix !== manifest.buildExpiresAtUnix ||
+ build.linuxClientSha256 !== manifest.linuxClientSha256 ||
+ build.linuxBrokerSha256 !== manifest.linuxBrokerSha256 ||
+ build.offlineBootstrapFeatureBits !== manifest.offlineBootstrapFeatureBits ||
+ build.offlineExportSealFormat !== manifest.offlineExportSealFormat ||
+ build.securityCheckpointCount !== manifest.securityCheckpointCount ||
+ build.securityCheckpointSetId !== manifest.securityCheckpointSetId ||
+ build.securityCheckpointSetSha256 !== manifest.securityCheckpointSetSha256 ||
+ build.securityNoticeId !== manifest.securityNoticeId ||
+ build.securityNoticeSha256 !== manifest.securityNoticeSha256
+ ) {
+ throw new Error("Linux native-core provenance build record does not match the manifest");
+ }
+ if (
+ (Object.prototype.hasOwnProperty.call(build, "linuxHostVerification") &&
+ build.linuxHostVerification !== manifest.linuxHostVerification) ||
+ (Object.prototype.hasOwnProperty.call(build, "sourceRuntime") &&
+ build.sourceRuntime !== manifest.sourceRuntime)
+ ) {
+ throw new Error("Linux native-core provenance build record does not match the manifest");
+ }
+
+ const pinErrors = linuxContentPinErrors({ directory: artifactDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`Refusing Linux native-core artifact: ${pinErrors.join("; ")}`);
+ }
+ const clientElf = inspectElf(path.join(artifactDir, CLIENT_NAME));
+ const brokerElf = inspectElf(path.join(artifactDir, BROKER_NAME));
+ if (!clientElf.isSharedObject) {
+ throw new Error("Linux native client must be an x86-64 ELF shared object");
+ }
+ if (!brokerElf.isExecutable) {
+ throw new Error("Linux native broker must be an x86-64 ELF executable");
+ }
+
+ return {
+ artifactDir,
+ manifest,
+ provenance,
+ repository,
+ sourceRevision,
+ buildId,
+ clientPin: manifest.linuxClientSha256,
+ brokerPin: manifest.linuxBrokerSha256,
+ names: [...RUNTIME_FILE_NAMES],
+ required: true,
+ };
+}
+
+module.exports = {
+ ARTIFACT_FILE_NAMES,
+ BROKER_NAME,
+ CHECKSUM_FILE_NAMES,
+ CLIENT_NAME,
+ HOST_VERIFICATION,
+ INTEGRITY_MODE,
+ MANIFEST_NAME,
+ PEER_VERIFICATION,
+ PRODUCER_WORKFLOW,
+ RUNTIME_FILE_NAMES,
+ inspectElf,
+ linuxContentPinErrors,
+ linuxNativeManifestErrors,
+ resolveLinuxNativeCoreArtifacts,
+};
diff --git a/desktop/scripts/native-core-before-pack.cjs b/desktop/scripts/native-core-before-pack.cjs
index 0137f44e..f728f0a8 100644
--- a/desktop/scripts/native-core-before-pack.cjs
+++ b/desktop/scripts/native-core-before-pack.cjs
@@ -9,6 +9,9 @@ const {
const {
assertWindowsNativeAsrCapability,
} = require("../src/windows-native-asr-capability.cjs");
+const {
+ linuxContentPinErrors,
+} = require("./linux-native-core-packaging.cjs");
const desktopRoot = path.resolve(__dirname, "..");
const LEGACY_WCDB_PATHS = [
@@ -185,6 +188,13 @@ function validatePackagedBackend({
if (platform === "win32") {
assertWindowsNativeAsrCapability({ nativeDir, manifest });
}
+ if (platform === "linux") {
+ // 打包后再验一次「内容哈希 pin」:这是 Linux 唯一的产物身份,必须逐字节站得住。
+ const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`Packaged Linux native core failed content verification: ${pinErrors.join("; ")}`);
+ }
+ }
return { backendDir, manifest, nativeDir, platform };
}
diff --git a/desktop/tests/native-core-packaging.test.cjs b/desktop/tests/native-core-packaging.test.cjs
index 082f64af..d7273dbf 100644
--- a/desktop/tests/native-core-packaging.test.cjs
+++ b/desktop/tests/native-core-packaging.test.cjs
@@ -1,5 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
+const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -19,6 +20,7 @@ const {
WINDOWS_NATIVE_ASR_TARGET,
} = require("../src/windows-native-asr-capability.cjs");
const { buildWindowsPeWithExports } = require("./pe-export-fixture.cjs");
+const { PRODUCER_WORKFLOW } = require("../scripts/linux-native-core-packaging.cjs");
const BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60;
const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60;
@@ -138,6 +140,155 @@ function quietLogger() {
return { log() {}, warn() {} };
}
+// ---- Linux(schema v4)固定件 -------------------------------------------------
+// Linux 没有代码签名,产物身份 = 内容哈希,所以固定件必须把哈希算对,
+// 否则测的就不是「校验逻辑」而是「固定件写错了」。
+const LINUX_BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60;
+const LINUX_REPOSITORY = "LifeArchiveProject/WCDB";
+const LINUX_SOURCE_REVISION = "a8f42de851a34365834e566bf587089af5df7c19";
+const LINUX_BUILD_ID = "linux-x64-release-2026.09.16";
+
+function sha256Hex(buffer) {
+ return crypto.createHash("sha256").update(buffer).digest("hex");
+}
+
+// 最小可用 ELF 头:测试只需要 64 位 / 小端 / x86-64 / 类型正确。
+function linuxElfBytes(type) {
+ const buffer = Buffer.alloc(64);
+ buffer.write("\x7fELF", 0, "latin1");
+ buffer[4] = 2;
+ buffer[5] = 1;
+ buffer.writeUInt16LE(type, 16);
+ buffer.writeUInt16LE(0x3e, 18);
+ return buffer;
+}
+
+function linuxManifest({ sourceRuntime = true, overrides = {} } = {}) {
+ return {
+ schemaVersion: 4,
+ platform: "linux",
+ distributionMode: "public",
+ buildId: LINUX_BUILD_ID,
+ buildIssuedAtUnix: LINUX_BUILD_ISSUED_AT_UNIX,
+ buildExpiresAtUnix: LINUX_BUILD_ISSUED_AT_UNIX + BUILD_LIFETIME_SECONDS,
+ developmentBuild: false,
+ offlineBootstrapFeatureBits: 3,
+ offlineExportSealFormat: "WES2",
+ codeSignatureEnforced: true,
+ rootPublicKeyCompiled: true,
+ testHooksEnabled: false,
+ stagingPinnedSignerTrust: false,
+ linuxIntegrityMode: "content-hash-pin",
+ linuxClientSha256: "",
+ linuxBrokerSha256: "",
+ linuxPeerVerification: "same-user-peer-credentials",
+ linuxHostVerification: sourceRuntime ? "same-user-direct-parent" : "content-hash-pin",
+ securityNoticeId: "WCE-AUTOMATED-ANALYSIS-NOTICE-V2",
+ securityNoticeSha256: "aa".repeat(32),
+ securityCheckpointSetId: "WCE-AI-CHECKPOINT-SET-V3",
+ securityCheckpointCount: 7,
+ securityCheckpointSetSha256: "bb".repeat(32),
+ ...(sourceRuntime ? { sourceRuntime: true } : {}),
+ ...overrides,
+ };
+}
+
+const LINUX_CHECKSUM_FILE_NAMES = [
+ "Test-LinuxNativeProductionArtifact.py",
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+];
+
+function writeLinuxArtifactSet(
+ root,
+ { sourceRuntime = true, manifestOverrides = {}, provenanceOverrides = {}, tamperClient = false } = {}
+) {
+ fs.mkdirSync(root, { recursive: true });
+ const clientName = "libwechatdb_client.so";
+ const brokerName = "wechatdb_broker";
+ const manifestName = "wechatdb_native_build.json";
+ const clientBytes = linuxElfBytes(3);
+ const brokerBytes = linuxElfBytes(2);
+
+ const manifest = linuxManifest({ sourceRuntime, overrides: manifestOverrides });
+ manifest.linuxClientSha256 = sha256Hex(clientBytes);
+ manifest.linuxBrokerSha256 = sha256Hex(brokerBytes);
+ Object.assign(manifest, manifestOverrides);
+
+ fs.writeFileSync(path.join(root, clientName), clientBytes);
+ fs.writeFileSync(path.join(root, brokerName), brokerBytes);
+ fs.writeFileSync(path.join(root, "Test-LinuxNativeProductionArtifact.py"), "# fixture\n");
+ fs.writeFileSync(path.join(root, manifestName), JSON.stringify(manifest, null, 2));
+
+ const checksums = LINUX_CHECKSUM_FILE_NAMES.map(
+ (name) => `${sha256Hex(fs.readFileSync(path.join(root, name)))} ${name}`
+ ).join("\n") + "\n";
+ fs.writeFileSync(path.join(root, "SHA256SUMS.txt"), checksums);
+
+ const provenance = {
+ schemaVersion: 1,
+ artifactName: "wechatdb-native-linux-x64-source-public",
+ producer: "manual",
+ workflow: "manual",
+ repository: LINUX_REPOSITORY,
+ runId: 0,
+ runAttempt: 0,
+ sourceRevision: LINUX_SOURCE_REVISION,
+ build: {
+ architecture: "x64",
+ distributionMode: manifest.distributionMode,
+ expiresAtUnix: manifest.buildExpiresAtUnix,
+ id: manifest.buildId,
+ integrityMode: manifest.linuxIntegrityMode,
+ issuedAtUnix: manifest.buildIssuedAtUnix,
+ linuxBrokerSha256: manifest.linuxBrokerSha256,
+ linuxClientSha256: manifest.linuxClientSha256,
+ offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits,
+ offlineExportSealFormat: manifest.offlineExportSealFormat,
+ platform: "linux",
+ readOnlyBuild: true,
+ securityCheckpointCount: manifest.securityCheckpointCount,
+ securityCheckpointSetId: manifest.securityCheckpointSetId,
+ securityCheckpointSetSha256: manifest.securityCheckpointSetSha256,
+ securityNoticeId: manifest.securityNoticeId,
+ securityNoticeSha256: manifest.securityNoticeSha256,
+ ...(sourceRuntime
+ ? { linuxHostVerification: manifest.linuxHostVerification, sourceRuntime: true }
+ : {}),
+ ...(provenanceOverrides.build || {}),
+ },
+ manifestSha256: sha256Hex(fs.readFileSync(path.join(root, manifestName))),
+ checksumsSha256: sha256Hex(fs.readFileSync(path.join(root, "SHA256SUMS.txt"))),
+ artifacts: LINUX_CHECKSUM_FILE_NAMES.map((name) => ({
+ path: name,
+ sha256: sha256Hex(fs.readFileSync(path.join(root, name))),
+ size: fs.statSync(path.join(root, name)).size,
+ })),
+ };
+ const { build: _ignoredBuild, ...provenanceTopLevel } = provenanceOverrides;
+ Object.assign(provenance, provenanceTopLevel);
+ fs.writeFileSync(path.join(root, "provenance.json"), JSON.stringify(provenance, null, 2));
+
+ if (tamperClient) {
+ // 密封之后再改字节:SHA256SUMS / provenance 仍然声称原始哈希。
+ const bytes = Buffer.from(fs.readFileSync(path.join(root, clientName)));
+ bytes[40] ^= 0xff;
+ fs.writeFileSync(path.join(root, clientName), bytes);
+ }
+ return { manifest, provenance };
+}
+
+function linuxEnv(artifactDir, overrides = {}) {
+ return {
+ WCE_NATIVE_CORE_ARTIFACT_DIR: artifactDir,
+ WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: LINUX_REPOSITORY,
+ WCE_NATIVE_CORE_SOURCE_REVISION: LINUX_SOURCE_REVISION,
+ WCE_NATIVE_CORE_BUILD_ID: LINUX_BUILD_ID,
+ ...overrides,
+ };
+}
+
test("artifact names are platform-specific and complete", () => {
assert.deepEqual(nativeCoreArtifactNames("win32"), [
"wechatdb_client.dll",
@@ -149,7 +300,11 @@ test("artifact names are platform-specific and complete", () => {
"wechatdb_broker",
"wechatdb_native_build.json",
]);
- assert.deepEqual(nativeCoreArtifactNames("linux"), []);
+ assert.deepEqual(nativeCoreArtifactNames("linux"), [
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+ ]);
});
test("runtime staging filters checked-out native and legacy WCDB files", () => {
@@ -599,20 +754,200 @@ test("malformed and structurally invalid manifests fail even with a development
);
assert.throws(
() => resolveNativeCoreArtifacts({ env, platform: "win32" }),
- /schemaVersion must equal 2 or 3; buildId must be a non-empty string/
+ /schemaVersion must equal 2, 3 or 4; buildId must be a non-empty string/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
-test("unsupported platforms stay optional but fail closed when configured", () => {
- const optional = resolveNativeCoreArtifacts({ env: {}, platform: "linux" });
- assert.equal(optional.artifactDir, null);
+test("Linux native core is a required closed artifact set", () => {
assert.throws(
- () => resolveNativeCoreArtifacts({ env: { WCE_NATIVE_CORE_REQUIRED: "yes" }, platform: "linux" }),
- /unsupported on platform: linux/
+ () => resolveNativeCoreArtifacts({ env: {}, platform: "linux" }),
+ /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/
);
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: { WCE_NATIVE_CORE_REQUIRED: "yes" },
+ platform: "linux",
+ }),
+ /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/
+ );
+});
+
+test("Linux source-public and production profiles both resolve from sealed artifacts", () => {
+ const root = makeTempDir();
+ try {
+ for (const sourceRuntime of [true, false]) {
+ const artifactDir = path.join(root, sourceRuntime ? "sp" : "prod");
+ writeLinuxArtifactSet(artifactDir, { sourceRuntime });
+ const resolved = resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir),
+ platform: "linux",
+ });
+ assert.equal(resolved.required, true);
+ assert.equal(resolved.allowDevelopment, false);
+ assert.deepEqual(resolved.names, [
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+ ]);
+ assert.equal(
+ resolved.manifest.linuxHostVerification,
+ sourceRuntime ? "same-user-direct-parent" : "content-hash-pin"
+ );
+ }
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux content-hash pins reject any post-seal tampering", () => {
+ const root = makeTempDir();
+ try {
+ const artifactDir = path.join(root, "tampered");
+ writeLinuxArtifactSet(artifactDir, { tamperClient: true });
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(artifactDir), platform: "linux" }),
+ /checksum set does not match the artifact allowlist/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux protected pins fail closed on build id, revision and repository drift", () => {
+ const root = makeTempDir();
+ try {
+ const artifactDir = path.join(root, "pinned");
+ writeLinuxArtifactSet(artifactDir);
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_BUILD_ID: "linux-x64-other-2026.09.16" }),
+ platform: "linux",
+ }),
+ /does not match the protected build id pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_SOURCE_REVISION: "0".repeat(40) }),
+ platform: "linux",
+ }),
+ /provenance revision does not match the protected pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: "evil/fork" }),
+ platform: "linux",
+ }),
+ /provenance repository does not match the protected pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "123" }),
+ platform: "linux",
+ }),
+ /must not claim a CI run/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux artifacts are only accepted from the reviewed producer workflow", () => {
+ const root = makeTempDir();
+ try {
+ // A workflow-produced artifact has to come from the reviewed producer, not
+ // from any workflow that happens to know the pin format.
+ const rogueDir = path.join(root, "rogue");
+ writeLinuxArtifactSet(rogueDir, {
+ provenanceOverrides: {
+ producer: "github-actions",
+ workflow: ".github/workflows/rogue-production.yml",
+ runId: 4242,
+ runAttempt: 1,
+ },
+ });
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(rogueDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }),
+ platform: "linux",
+ }),
+ /must come from \.github\/workflows\/linux-native-production\.yml/
+ );
+
+ const reviewedDir = path.join(root, "reviewed");
+ writeLinuxArtifactSet(reviewedDir, {
+ provenanceOverrides: {
+ producer: "github-actions",
+ workflow: PRODUCER_WORKFLOW,
+ runId: 4242,
+ runAttempt: 1,
+ },
+ });
+ const resolved = resolveNativeCoreArtifacts({
+ env: linuxEnv(reviewedDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }),
+ platform: "linux",
+ });
+ assert.equal(resolved.provenance.runId, 4242);
+ assert.equal(resolved.provenance.workflow, PRODUCER_WORKFLOW);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux profile self-consistency and binary identity are enforced", () => {
+ const root = makeTempDir();
+ try {
+ // 声明 sourceRuntime 却用 production 强度的 host 校验:必须拒绝。
+ const inconsistent = path.join(root, "inconsistent");
+ writeLinuxArtifactSet(inconsistent, {
+ manifestOverrides: { linuxHostVerification: "content-hash-pin" },
+ });
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(inconsistent), platform: "linux" }),
+ /linuxHostVerification must equal same-user-direct-parent/
+ );
+
+ // 客户端不是 ELF:必须拒绝。
+ const notElf = path.join(root, "not-elf");
+ writeLinuxArtifactSet(notElf);
+ fs.writeFileSync(path.join(notElf, "libwechatdb_client.so"), "not an elf at all");
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(notElf), platform: "linux" }),
+ /checksum set does not match the artifact allowlist/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("packaged Linux native core is re-hashed before packing", () => {
+ const root = makeTempDir();
+ try {
+ const { validatePackagedBackend } = require("../scripts/native-core-before-pack.cjs");
+ const nativeDir = path.join(root, "native");
+ writeLinuxArtifactSet(nativeDir);
+ fs.writeFileSync(path.join(root, "wechat-backend"), "# packaged backend\n");
+
+ const validated = validatePackagedBackend({ backendDir: root, platform: "linux" });
+ assert.equal(validated.platform, "linux");
+
+ // 打包后再被替换一个字节 → 内容哈希必须拦住。
+ fs.writeFileSync(path.join(nativeDir, "wechatdb_broker"), linuxElfBytes(3));
+ assert.throws(
+ () => validatePackagedBackend({ backendDir: root, platform: "linux" }),
+ /Packaged Linux native core failed content verification: content hash mismatch for wechatdb_broker/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
});
test("boolean packaging flags reject ambiguous values", () => {
diff --git a/desktop/tests/package-config.test.cjs b/desktop/tests/package-config.test.cjs
index 132318fe..f61c8098 100644
--- a/desktop/tests/package-config.test.cjs
+++ b/desktop/tests/package-config.test.cjs
@@ -10,6 +10,42 @@ const desktopRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(desktopRoot, "..");
const packageJson = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8"));
+// Every remote action a release workflow may reference, pinned to an approved
+// commit. Both the tag-triggered release workflow and the platform build
+// workflows it calls are checked against this single list.
+const APPROVED_ACTIONS = new Map([
+ ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"],
+ ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"],
+ ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"],
+ ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"],
+ ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"],
+ ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"],
+ ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"],
+ ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"],
+ ["H3CoF6/qq-notify-action", "50d180981e7c7b8552a3331b981e3f8cfcf40c44"],
+]);
+
+function assertRemoteActionsPinned(workflow) {
+ const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)]
+ .map((match) => match[1])
+ .filter((use) => !use.startsWith("./"));
+ assert.ok(remoteUses.length > 0);
+ for (const use of remoteUses) {
+ const separator = use.lastIndexOf("@");
+ const action = use.slice(0, separator);
+ const revision = use.slice(separator + 1);
+ assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`);
+ assert.equal(revision, APPROVED_ACTIONS.get(action), `${action} uses an unapproved commit`);
+ }
+ return remoteUses;
+}
+
+function readWorkflow(name) {
+ return fs
+ .readFileSync(path.join(repoRoot, ".github", "workflows", name), "utf8")
+ .replace(/\r\n/g, "\n");
+}
+
test("desktop package excludes the retired Koffi and WCDB sidecar runtime", () => {
const nodeModulesRule = packageJson.build.files.find(
(item) => item && typeof item === "object" && item.from === "node_modules"
@@ -287,32 +323,7 @@ test("Windows release uses protected cloud private-PKI signing and installer smo
});
test("release workflow pins every remote action to an approved commit", () => {
- const workflow = fs
- .readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")
- .replace(/\r\n/g, "\n");
- const approved = new Map([
- ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"],
- ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"],
- ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"],
- ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"],
- ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"],
- ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"],
- ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"],
- ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"],
- ["H3CoF6/qq-notify-action", "50d180981e7c7b8552a3331b981e3f8cfcf40c44"],
- ]);
- const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)]
- .map((match) => match[1])
- .filter((use) => !use.startsWith("./"));
-
- assert.ok(remoteUses.length > 0);
- for (const use of remoteUses) {
- const separator = use.lastIndexOf("@");
- const action = use.slice(0, separator);
- const revision = use.slice(separator + 1);
- assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`);
- assert.equal(revision, approved.get(action), `${action} uses an unapproved commit`);
- }
+ const remoteUses = assertRemoteActionsPinned(readWorkflow("release.yml"));
for (const action of [
"actions/checkout",
"actions/setup-node",
@@ -321,7 +332,112 @@ test("release workflow pins every remote action to an approved commit", () => {
"actions/upload-artifact",
"softprops/action-gh-release",
]) {
- assert.ok(remoteUses.includes(`${action}@${approved.get(action)}`), `${action} is missing`);
+ assert.ok(
+ remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`),
+ `${action} is missing`
+ );
+ }
+});
+
+test("the tag release requires and publishes the Linux x64 package", () => {
+ const workflow = readWorkflow("release.yml");
+ const releaseJob = workflow.match(
+ /\n build-linux-x64:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/
+ )?.[1] || "";
+ assert.match(releaseJob, /uses:\s*\.\/\.github\/workflows\/linux-private-build\.yml/);
+ assert.match(releaseJob, /secrets:\s*inherit/);
+
+ const publishJob = workflow.match(
+ /\n publish-release:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/
+ )?.[1] || "";
+ assert.match(publishJob, /- build-windows/);
+ assert.match(publishJob, /- build-macos-arm64/);
+ // Linux is a required platform: a missing native-core pin fails the release
+ // instead of silently publishing without it.
+ assert.match(publishJob, /- build-linux-x64/);
+
+ const qqJob = workflow.match(/\n qq-notify:\n([\s\S]*?)$/)?.[1] || "";
+ assert.match(qqJob, /linux_url/);
+ assert.match(qqJob, /WeChatDataAnalysis-\$\{VER\}-linux-x86_64\.tar\.gz/);
+ assert.match(qqJob, /install\.sh/);
+});
+
+test("Linux release workflow consumes the pinned native core and publishes the unrooted payload", () => {
+ const workflow = readWorkflow("linux-private-build.yml");
+ const job = workflow.match(
+ /\n build-linux-x64:\n([\s\S]*?)$/
+ )?.[1] || "";
+ assert.ok(job, "build-linux-x64 job is missing");
+ assert.match(workflow, /workflow_call:/);
+ assert.match(workflow, /workflow_dispatch:/);
+ assert.match(job, /runs-on:\s*ubuntu-22\.04/);
+ assert.match(job, /if:\s*github\.ref == 'refs\/heads\/main' \|\| startsWith\(github\.ref, 'refs\/tags\/v'\)/);
+
+ // The repository variables live in the WCE_LINUX_ namespace while the
+ // consumer module keeps reading the platform-neutral WCE_NATIVE_CORE_ names.
+ for (const [variable, envName] of [
+ ["WCE_LINUX_NATIVE_CORE_ARTIFACT_REPOSITORY", "WCE_NATIVE_CORE_ARTIFACT_REPOSITORY"],
+ ["WCE_LINUX_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY", "WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY"],
+ ["WCE_LINUX_NATIVE_CORE_ARTIFACT_SHA256", "WCE_NATIVE_CORE_ARTIFACT_SHA256"],
+ ["WCE_LINUX_NATIVE_CORE_ARTIFACT_RUN_ID", "WCE_NATIVE_CORE_ARTIFACT_RUN_ID"],
+ ["WCE_LINUX_NATIVE_CORE_SOURCE_REVISION", "WCE_NATIVE_CORE_SOURCE_REVISION"],
+ ["WCE_LINUX_NATIVE_CORE_BUILD_ID", "WCE_NATIVE_CORE_BUILD_ID"],
+ ["WCE_LINUX_NATIVE_CORE_CLIENT_SHA256", "WCE_NATIVE_CORE_CLIENT_SHA256"],
+ ["WCE_LINUX_NATIVE_CORE_BROKER_SHA256", "WCE_NATIVE_CORE_BROKER_SHA256"],
+ ]) {
+ assert.match(
+ job,
+ new RegExp(`${envName}:\\s*\\$\\{\\{\\s*vars\\.${variable}\\s*\\}\\}`),
+ `${variable} is not wired`
+ );
+ }
+ assert.match(job, /secrets\.WCE_LINUX_PRODUCER_READ_TOKEN/);
+ assert.doesNotMatch(job, /WCE_INTEGRITY_ARTIFACT_DIR/);
+
+ const order = [
+ "Verify immutable source and protected pins",
+ "Download the pinned Producer native-core artifact",
+ "Validate the pinned native core against the production policy",
+ "Checkout the pinned private integrity source",
+ "Build the Linux package",
+ "Verify the packaged Linux runtime",
+ "Prepare Linux release checksums and provenance",
+ "Upload Linux release files",
+ ];
+ let previous = -1;
+ for (const step of order) {
+ const index = job.indexOf(step);
+ assert.ok(index >= 0, `${step} is missing`);
+ assert.ok(index > previous, `${step} is out of order`);
+ previous = index;
+ }
+
+ assert.match(job, /gh release download "\$release_tag"/);
+ assert.match(job, /gh run download "\$WCE_NATIVE_CORE_ARTIFACT_RUN_ID"/);
+ assert.match(job, /test "\$actual_sha256" = "\$WCE_NATIVE_CORE_ARTIFACT_SHA256"/);
+ assert.match(job, /resolveLinuxNativeCoreArtifacts\(\{ platform: 'linux' \}\)/);
+ assert.match(job, /repos\/\$LINUX_INTEGRITY_SOURCE_REPOSITORY\/tarball\/\$LINUX_INTEGRITY_SOURCE_REVISION/);
+ assert.match(job, /native\/wce_integrity\/Cargo\.toml/);
+ assert.doesNotMatch(job, /cargo build/);
+ assert.match(job, /tests\/test_linux_db_key_flow\.py/);
+ assert.doesNotMatch(job, /test_wcdb_realtime_native_core_required\.py/);
+ assert.doesNotMatch(job, /test_native_core_broker_lifecycle\.py/);
+ assert.match(job, /npm run dist:linux/);
+ assert.match(job, /differs from the reviewed native artifact/);
+ assert.match(job, /linuxContentPinErrors/);
+ assert.match(job, /SHA256SUMS-linux\.txt/);
+ assert.match(job, /release-provenance-linux\.json/);
+ assert.match(job, /desktop\/dist\/\*-linux-x86_64\.tar\.gz/);
+ assert.match(job, /name:\s*release-linux-x64/);
+});
+
+test("the Linux release workflow pins every remote action to an approved commit", () => {
+ const remoteUses = assertRemoteActionsPinned(readWorkflow("linux-private-build.yml"));
+ for (const action of ["actions/checkout", "actions/upload-artifact"]) {
+ assert.ok(
+ remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`),
+ `${action} is missing`
+ );
}
});
@@ -635,13 +751,13 @@ test("macOS DMG cleanup preserves both detach failures", () => {
);
});
-test("tag release reuses the protected macOS build and publishes both platforms", () => {
+test("tag release reuses the protected platform builds and publishes every platform", () => {
const workflow = fs
.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")
.replace(/\r\n/g, "\n");
const publishJob = workflow.split("\n publish-release:\n", 2)[1] || "";
- assert.match(workflow, /^name: Release \(Windows and macOS ARM64\)$/m);
+ assert.match(workflow, /^name: Release \(Windows, macOS ARM64 and Linux x64\)$/m);
assert.match(
workflow,
/\n build-macos-arm64:\n\s+uses: \.\/\.github\/workflows\/macos-private-build\.yml\n\s+secrets: inherit/
@@ -696,3 +812,40 @@ test("frontend joins copied output paths using the native path style", async ()
assert.equal(joinNativePath("D:\\wechat\\output\\", "wxid_demo"), "D:\\wechat\\output\\wxid_demo");
assert.equal(joinNativePath("\\\\server\\share\\output", "wxid_demo"), "\\\\server\\share\\output\\wxid_demo");
});
+
+test("Linux ships as an unpacked directory plus a checksum-verified install script", async () => {
+ // 刻意不做 AppImage / deb:Linux 的形态是 dist/linux-unpacked + install.sh。
+ assert.deepEqual(packageJson.build.linux.target, ["dir"]);
+ assert.equal(packageJson.build.linux.executableName, "wechat-data-analysis");
+ assert.equal(packageJson.build.linux.icon, "src/icon.png");
+ assert.match(packageJson.scripts["dist:linux"], /electron-builder --linux dir --x64/);
+ assert.match(packageJson.scripts["dist:linux"], /build-linux-installer\.cjs/);
+
+ const os = require("os");
+ const { spawnSync } = require("child_process");
+ const { buildLinuxInstaller } = require("../scripts/build-linux-installer.cjs");
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-linux-installer-"));
+ try {
+ const payloadDir = path.join(root, "linux-unpacked");
+ fs.mkdirSync(path.join(payloadDir, "resources"), { recursive: true });
+ fs.writeFileSync(path.join(payloadDir, "wechat-data-analysis"), "#!/bin/sh\nexit 0\n");
+ fs.chmodSync(path.join(payloadDir, "wechat-data-analysis"), 0o755);
+
+ const result = buildLinuxInstaller({ payloadDir, outputDir: path.join(root, "dist") });
+ assert.ok(fs.existsSync(result.archivePath));
+ assert.ok(fs.existsSync(result.installerPath));
+ assert.equal(result.sha256, crypto.createHash("sha256").update(fs.readFileSync(result.archivePath)).digest("hex"));
+
+ const installer = fs.readFileSync(result.installerPath, "utf8");
+ assert.equal(installer.includes("@@"), false, "installer must not keep template placeholders");
+ assert.match(installer, new RegExp(result.sha256));
+ assert.match(installer, /PAYLOAD_SHA256=/);
+ assert.match(installer, /--uninstall/);
+ assert.match(installer, /wechat-data-analysis\.desktop/);
+
+ const syntax = spawnSync("sh", ["-n", result.installerPath], { encoding: "utf8" });
+ assert.equal(syntax.status, 0, syntax.stderr);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/docs/linux-release.md b/docs/linux-release.md
new file mode 100644
index 00000000..5d46436b
--- /dev/null
+++ b/docs/linux-release.md
@@ -0,0 +1,72 @@
+# Linux 发布流程(x64)
+
+Linux 与 Windows / macOS 一起发在同一个 tag Release 里,并且是**必需平台**:原生组件的 pin
+没配齐时,`release.yml` 会直接失败,而不是静默少发一个平台。
+
+产物形态刻意不做 AppImage / deb:Linux 走「用户级、免 root 的 `tar.gz` + `install.sh`」。
+
+## 涉及的三个工作流
+
+| 工作流 | 位置 | 作用 |
+| --- | --- | --- |
+| `linux-native-production.yml` | **WCDB**(私藏 producer 仓) | 手工 dispatch:构建 + 自检 + 把原生核心发成不可变 Release 资产,并把消费方要的 pin 打到 run summary |
+| `linux-private-build.yml` | 本仓 | 可复用构建:下载 pin 产物 → 校验 → 编译 integrity → `dist:linux` → 打包校验 → 上传 |
+| `release.yml` | 本仓 | `push tag v*` 触发;`build-linux-x64` 调用上面的可复用工作流,`publish-release` 汇总三个平台 |
+
+## 操作顺序
+
+1. **产原生核心**(在 WCDB producer 仓):Actions → `Linux native production` → Run workflow。
+ - `profile` = `source-public`(WCDA 只收这个 profile)
+ - `build_id` 留空 → 取 `linux-x64-`;**重跑必须显式传新 ID**,已发布的 ID 不允许复用
+ - `publish_release` = true
+ - 需要 secret/var `WCE_ROOT_PUBLIC_KEY_HEX`(128 hex,P-256 **公钥**,不是私钥)
+ - 结束后在 run summary 里复制那段 `WCE_LINUX_NATIVE_CORE_*` 变量清单
+2. **配本仓变量**(Settings → Secrets and variables → Actions):
+
+ | 类型 | 名称 | 说明 |
+ | --- | --- | --- |
+ | variable | `WCE_LINUX_NATIVE_CORE_ARTIFACT_REPOSITORY` | producer 仓 `owner/repo`(= 运行工作流的那个仓) |
+ | variable | `WCE_LINUX_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY` | 资产当前托管在哪;留空则同上 |
+ | variable | `WCE_LINUX_NATIVE_CORE_ARTIFACT_RUN_ID` | producer 的 run id |
+ | variable | `WCE_LINUX_NATIVE_CORE_ARTIFACT_SHA256` | 资产 tar.gz 的摘要 |
+ | variable | `WCE_LINUX_NATIVE_CORE_SOURCE_REVISION` | 40 hex 的 WCDB revision |
+ | variable | `WCE_LINUX_NATIVE_CORE_BUILD_ID` | 构建 ID |
+ | variable | `WCE_LINUX_NATIVE_CORE_CLIENT_SHA256` / `..._BROKER_SHA256` | 可选的内容 pin;一旦填就必须与 manifest 一致 |
+ | variable | `WCE_LINUX_INTEGRITY_SOURCE_REPOSITORY` / `..._SOURCE_REVISION` | 可选;默认取原生核心的仓库/revision(`private/wce_integrity` 在同一棵树里) |
+ | secret | `WCE_LINUX_PRODUCER_READ_TOKEN` | 对 producer 仓有读权限的 token(资产下载 + 取 integrity 源码) |
+3. **发版**:推 tag `v*`。tag 必须在 `origin/main` 上。
+
+## 为什么可以不做代码签名
+
+Linux 没有 Authenticode / codesign 的等价物,所以身份改成**内容哈希**,方向是单向的:
+
+- broker 里编死了「随包 client 的哈希」;
+- broker 自己的哈希由 manifest 声明、由安装方 pin。
+
+`sha256(file) == pin(file)` 无解,所以「组件自带自身哈希」这种不可判定的方向被显式禁止:
+`Test-LinuxNativeProductionArtifact.py` 与 `desktop/scripts/linux-native-core-packaging.cjs`
+两头都断言了这一点。消费侧还会在打包后再哈希一次(`packaged ... differs from the reviewed
+native artifact`),确保打包过程没有顺手重编。
+
+导出完整性模块 `libwce_integrity.so` 由本仓在发布时用**一次性构建密钥**现编(语义等同于
+官方的 `-GenerateEphemeralSigningKey`):该密钥只用于导出物自身封签,权威封印是原生核心产出的
+WES2 sidecar。之所以不在 producer 侧预编,是因为 `wce_integrity` 会把 Nuxt 的 CSS 编进去,
+必须和当次 UI 构建同源。
+
+## 会踩的坑
+
+- **45 天有效期**:pin 的 manifest 固定 45 天窗口,到期后 `build-linux-x64` 会在校验阶段
+ 明确报 `build has reached its fixed expiration time`。到期必须重新产一份并更新 pin。
+- **构建 ID 不可复用**:producer 在发布前会检查 `linux-native-` 是否已存在,存在即拒绝。
+- **校验失败就是失败**:`build-linux-x64` 不设 `continue-on-error`,`publish-release.needs` 包含它,
+ 所以 pin 缺失 / 摘要不符 / 哈希漂移都会让 release 停在半路而不是发出去。
+- **产物名不能重**:Linux 用 `SHA256SUMS-linux.txt` / `release-provenance-linux.json`,
+ 避免与 Windows 的 `SHA256SUMS.txt` / `release-provenance.json` 在 `merge-multiple` 下载时互相覆盖。
+
+## 已知缺口(发版前必须处理)
+
+`desktop/src/native-core-runtime.cjs` 目前只认 `win32` / `darwin`:在 Linux 上
+`resolveNativeCoreRuntimePolicy()` 会抛 `unsupported on platform: linux`,而它由
+`startBackend()` 无保护调用 —— 即打出来的 Linux 包**启动后端就会失败**。
+`desktop/tests/native-core-runtime.test.cjs` 在 Linux 上因此跑不过,所以没有进
+`build-linux-x64` 的桌面测试门禁。在修好之前,Linux 的 release 只是「能出包」,不是「能用」。
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 455885e4..e0599a8f 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -59,7 +59,7 @@ export default defineNuxtConfig({
// 「高级功能」弹窗复用官网的 pro-demos 演示引擎(website/assets 下),跨根导入需要别名,
// 并让 dev server 额外放行 website/assets(保留 Vite 默认推断的工作区根,不把整个仓库暴露给 /@fs/)
vite: {
- ssr: { noExternal: [/^@assistant-ui\//] },
+ ssr: { noExternal: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue'] },
resolve: {
alias: [...assistantUiAliases, { find: '@website', replacement: websiteAssetsDir }]
},
diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue
index 9edf61aa..5b82dc43 100644
--- a/frontend/pages/decrypt.vue
+++ b/frontend/pages/decrypt.vue
@@ -82,9 +82,11 @@
{{ isMacos
? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性本机调试兜底。获取接口仅允许本机访问。'
+ : isLinux
+ ? '点击按钮将由 wx_key 拉起微信并在弹出的窗口中完成登录以获取【数据库解密密钥】;Linux 不执行内存扫描。您也可以手动输入已知的64位密钥。'
: '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}
-
+
@@ -119,7 +121,7 @@
id="dbPath"
v-model="formData.db_storage_path"
type="text"
- :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'"
+ :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : isLinux ? '例如: /home/你的用户名/Documents/xwechat_files/wxid_xxx/db_storage' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'"
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
:class="{ 'border-red-500': formErrors.db_storage_path }"
required
@@ -1130,6 +1132,9 @@ const macosKeyCaptureCleanupInFlight = ref(false)
const platformCapabilities = ref({ platform: '' })
const platformCapabilitiesLoaded = ref(false)
const isMacos = computed(() => platformCapabilities.value?.platform === 'macos')
+const isLinux = computed(() => platformCapabilities.value?.platform === 'linux')
+// 路径分隔符:只有 Windows 用反斜杠,Linux 与 macOS 一样是正斜杠。
+const pathSeparator = computed(() => (platformCapabilities.value?.platform === 'windows' ? '\\' : '/'))
const imageKeyMemoryScanChecking = computed(() => !platformCapabilitiesLoaded.value)
const imageKeyMemoryScanSupported = computed(() => {
if (!platformCapabilitiesLoaded.value) return false
@@ -2245,20 +2250,40 @@ const handleGetDbKey = async () => {
return
}
- const shouldContinue = await requestGuideDialog({
- eyebrow: '密钥获取提示',
- title: '获取前请确认微信已登录',
- description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。',
- details: [
- '保持电脑版微信运行,并登录需要解密的账号',
- '确认下方数据库路径属于同一个微信账号',
- '获取期间不要退出微信或切换到其他账号'
- ],
- note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。',
- primaryLabel: '准备好了,开始获取',
- secondaryLabel: '暂不获取',
- tone: 'guide'
- })
+ // Linux 没有 V4 内存扫描这一套逻辑(见 platform_support / key_service 的说明):
+ // wx_key 采用 fork + TRACEME 自己拉起微信,一次到位,不存在「先扫内存、失败再改用 Hook」。
+ // 因此这里不能走 Windows 的提示与兜底流程,否则会先误导用户「正在扫描内存」,
+ // 再弹一次永远不可能成功的「内存扫描失败,是否改用 Hook?」。
+ const shouldContinue = await requestGuideDialog(isLinux.value
+ ? {
+ eyebrow: '密钥获取提示',
+ title: '获取前请确认微信已登录',
+ description: '获取密钥时会由 wx_key 拉起微信,请在它弹出的微信窗口里完成登录;Linux 不执行内存扫描。',
+ details: [
+ '获取时会先关闭正在运行的微信,再由 wx_key 重新拉起',
+ '请关闭微信的「自动登录」,在弹出的窗口里手动登录同一个账号',
+ '程序不能以 root 运行,否则 AppImage 版微信没有窗口',
+ '获取期间不要退出微信或切换到其他账号'
+ ],
+ note: '这里只做操作提醒,不会强制检查登录状态。',
+ primaryLabel: '准备好了,开始获取',
+ secondaryLabel: '暂不获取',
+ tone: 'guide'
+ }
+ : {
+ eyebrow: '密钥获取提示',
+ title: '获取前请确认微信已登录',
+ description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。',
+ details: [
+ '保持电脑版微信运行,并登录需要解密的账号',
+ '确认下方数据库路径属于同一个微信账号',
+ '获取期间不要退出微信或切换到其他账号'
+ ],
+ note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。',
+ primaryLabel: '准备好了,开始获取',
+ secondaryLabel: '暂不获取',
+ tone: 'guide'
+ })
if (!shouldContinue) return
const requestRevision = ++dbKeyRequestRevision
@@ -2313,7 +2338,11 @@ const handleGetDbKey = async () => {
}
let res = null
- if (dbStoragePath) {
+ if (isLinux.value) {
+ // Linux 直接走 Hook:没有内存扫描可尝试,后端也会拒绝 key_v4 模式。
+ res = await fetchByHook()
+ if (!isDbKeyRequestActive(requestRevision, requestController)) return
+ } else if (dbStoragePath) {
warning.value = '正在优先尝试 V4 内存扫描获取数据库密钥。'
res = await getKeys({
wechat_install_path: wechatInstallPath,
@@ -3586,8 +3615,9 @@ onMounted(async () => {
platformCapabilities.value = await getPlatformCapabilities()
} catch {
const macos = /Macintosh|Mac OS X/i.test(String(navigator.userAgent || ''))
+ const linux = !macos && /Linux/i.test(String(navigator.userAgent || ''))
platformCapabilities.value = {
- platform: macos ? 'macos' : 'windows',
+ platform: macos ? 'macos' : linux ? 'linux' : 'windows',
database_key_extraction: !macos,
database_key_guidance: macos
? '未能确认 macOS 数据库密钥组件,请检查本地服务或更新完整应用。'
@@ -3595,6 +3625,8 @@ onMounted(async () => {
image_key_memory_scan: !macos,
image_key_memory_scan_note: macos
? '未能确认 macOS 图片密钥扫描资源,请检查本地服务后重试。'
+ : linux
+ ? '未能确认 Linux 平台的 wx_key 组件,请检查本地服务后重试。'
: ''
}
} finally {
@@ -3609,7 +3641,7 @@ onMounted(async () => {
const account = JSON.parse(selectedAccount)
// 填充数据路径
if (account.data_dir) {
- const separator = isMacos.value ? '/' : '\\'
+ const separator = pathSeparator.value
formData.db_storage_path = String(account.data_dir).replace(/[\\/]+$/, '') + separator + 'db_storage'
}
if (account.account_name) {
diff --git a/frontend/tests/assistant-ui-ssr-external.test.js b/frontend/tests/assistant-ui-ssr-external.test.js
new file mode 100644
index 00000000..dfe15bdd
--- /dev/null
+++ b/frontend/tests/assistant-ui-ssr-external.test.js
@@ -0,0 +1,43 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+// 这个契约靠注释守不住,必须由测试守:
+// @assistant-ui/tap 的 react-shim 会 `import ... from "react"`,而 react 只是它的可选 peer,
+// 前端用 lib/assistant-ui-aliases.js 把 react 指到 standalone-shim。一旦这些包在 SSR 里被判为
+// external,就交给 Node 原生加载,Node 解析不到 react,/chat/[username] 直接 500。
+//
+// 实测坑:写成正则(/^@assistant-ui\//)会静默失效 —— Nuxt 把用户提供的 RegExp 序列化成字符串,
+// 于是它变成字面量 glob、永远匹配不上;同一条配置里 Nuxt 自带的条目仍然是 RegExp。所以这里
+// 必须钉住「包名字符串」这种写法。
+const configSource = readFileSync(resolve(process.cwd(), 'nuxt.config.ts'), 'utf8')
+
+function readNoExternalEntries() {
+ const match = configSource.match(/ssr:\s*\{\s*noExternal:\s*\[([^\]]*)\]/)
+ expect(match, 'nuxt.config.ts 里必须有 vite.ssr.noExternal').not.toBeNull()
+ return match[1]
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+}
+
+describe('assistant-ui 的 SSR 内联契约', () => {
+ it('noExternal 覆盖四个 assistant-ui 包,且写成包名字符串', () => {
+ const entries = readNoExternalEntries()
+ for (const name of ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue']) {
+ expect(entries, `${name} 必须出现在 noExternal 里`).toContain(`'${name}'`)
+ }
+ })
+
+ it('noExternal 不允许出现正则字面量(RegExp 会被序列化成字符串而静默失效)', () => {
+ const entries = readNoExternalEntries()
+ const regexLike = entries.filter((entry) => entry.startsWith('/') || entry.startsWith('('))
+ expect(regexLike, `noExternal 里不能写正则: ${regexLike.join(', ')}`).toEqual([])
+ })
+
+ it('react 仍然通过别名指向 standalone-shim(配合 noExternal 一起生效)', () => {
+ const aliases = readFileSync(resolve(process.cwd(), 'lib/assistant-ui-aliases.js'), 'utf8')
+ expect(aliases).toContain("^react$")
+ expect(aliases).toContain('@assistant-ui/tap/standalone-shim')
+ })
+})
diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js
index 28130bd9..27f4fb93 100644
--- a/frontend/vitest.config.js
+++ b/frontend/vitest.config.js
@@ -14,6 +14,11 @@ export default defineConfig({
},
test: {
environment: 'happy-dom',
- include: ['tests/**/*.test.js']
+ include: ['tests/**/*.test.js'],
+ server: {
+ deps: {
+ inline: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue']
+ }
+ }
}
})
diff --git a/pyproject.toml b/pyproject.toml
index 84fb8a7c..26c56696 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
"pilk>=0.2.4",
"pypinyin>=0.53.0",
"jieba>=0.42.1",
- "wx_key>=2.0.1; sys_platform == 'win32'",
+ "wx_key>=2.1.1; sys_platform == 'win32' or sys_platform == 'linux'",
"pefile>=2024.8.26; sys_platform == 'win32'",
"pymem>=1.14.0; sys_platform == 'win32'",
"yara-python>=4.5.2; sys_platform == 'win32'",
diff --git a/src/wechat_decrypt_tool/key_service.py b/src/wechat_decrypt_tool/key_service.py
index 872cd578..ff77602d 100644
--- a/src/wechat_decrypt_tool/key_service.py
+++ b/src/wechat_decrypt_tool/key_service.py
@@ -1,7 +1,7 @@
# import sys
# import requests
-from .platform_support import is_macos, is_windows
+from .platform_support import is_linux, is_macos, is_windows
try:
import wx_key
@@ -49,6 +49,19 @@
logger = logging.getLogger(__name__)
WECHAT_EXECUTABLE_NAMES = ("Weixin.exe", "WeChat.exe")
+# Linux 版微信的可执行文件名:发行版包一般是 /usr/bin/wechat(符号链接到
+# /opt/wechat/wechat),本地安装则可能在 ~/.local/bin,AppImage 用户是 *.AppImage。
+LINUX_WECHAT_EXECUTABLE_NAMES = ("wechat", "wechat-bin")
+LINUX_WECHAT_EXECUTABLE_PATHS = (
+ "/usr/bin/wechat",
+ "/opt/wechat/wechat",
+ "/usr/local/bin/wechat",
+ "~/.local/bin/wechat",
+)
+
+
+def _wechat_executable_names() -> tuple[str, ...]:
+ return LINUX_WECHAT_EXECUTABLE_NAMES if is_linux() else WECHAT_EXECUTABLE_NAMES
KEY_SIZE = 32
V4_DB_NAME_PRIORITY = (
"msg0.db",
@@ -170,6 +183,10 @@ def _read_wechat_version_from_exe(exe_path: str) -> str:
normalized = _normalize_user_path(exe_path)
if not normalized:
return ""
+ if is_linux():
+ # Linux 侧没有 PE 版本资源可读,版本号不是必需信息(只用于展示/日志),
+ # 因此这里不编造,调用方按"未知版本"处理。
+ return ""
try:
import win32api
@@ -189,6 +206,27 @@ def _resolve_manual_wechat_exe_path(wechat_install_path: Optional[str] = None) -
if not normalized:
return ""
+ if is_linux():
+ # Linux 上"安装目录"这个概念很弱(发行版包 / AppImage / 解包目录都行),
+ # 因此只要求:是个可执行文件,或者目录里能找到标准的 wechat 可执行文件。
+ candidate = Path(normalized).expanduser()
+ if candidate.is_file():
+ if not os.access(candidate, os.X_OK):
+ raise RuntimeError(f"手动指定的微信文件不可执行: {candidate}")
+ return str(candidate)
+ if candidate.is_dir():
+ for exe_name in LINUX_WECHAT_EXECUTABLE_NAMES:
+ exe_path = candidate / exe_name
+ if exe_path.is_file():
+ return str(exe_path)
+ for exe_path in sorted(candidate.glob("*.AppImage")):
+ if exe_path.is_file():
+ return str(exe_path)
+ raise RuntimeError(
+ f"手动指定的目录中没有可用的微信可执行文件: {candidate}"
+ )
+ raise RuntimeError(f"手动指定的微信路径不存在: {candidate}")
+
candidate = Path(normalized).expanduser()
executable_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
if candidate.is_file():
@@ -546,7 +584,7 @@ def _try_recover(candidate_internal_db_key: bytes, source: str) -> str:
class WeChatKeyFetcher:
def __init__(self):
- self.process_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
+ self.process_names = {name.lower() for name in _wechat_executable_names()}
self.timeout_seconds = 60
def _is_wechat_process(self, name: Any) -> bool:
@@ -629,12 +667,33 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
logger.info(f"Detect WeChat: {version or 'unknown'} at {exe_path}")
- self.kill_wechat()
- pid = self.launch_wechat(exe_path)
- logger.info(f"WeChat launched, PID: {pid}")
+ if is_linux():
+ # Linux 的 wx_key ABI 与 Windows 不同:第一个参数是**微信可执行文件路径**,
+ # 由 wx_key 自己 fork + PTRACE_TRACEME 拉起微信,我们绝不能先自己 launch。
+ #
+ # 为什么能免提权:TRACEME 场景下 ptrace 的规则是"父进程追踪自己的子进程",
+ # Yama/ptrace_scope=1 也放行;而 attach 一个已在运行的微信则会被拦下、必须
+ # 提权(这也正是 Linux 不提供 v4 内存扫描的原因)。
+ #
+ # 提权边界必须干净:本进程若以 root 运行,被拉起的 AppImage 会因为
+ # **FUSE 对 root 不可见**而挂载失败(表现是"微信没有窗口"),所以在提权
+ # 发生之前就拒绝,而不是让用户面对一个静默失败的微信。
+ if os.geteuid() == 0:
+ raise RuntimeError(
+ "请以普通用户身份运行本程序后再获取密钥:root 环境无法挂载 "
+ "AppImage 版微信(FUSE 对 root 不可见),会表现为微信没有窗口。"
+ )
+ self.kill_wechat()
+ logger.info("[db_key] Linux hook:交给 wx_key 拉起微信: %s", exe_path)
+ armed = wx_key.initialize_hook(exe_path)
+ else:
+ self.kill_wechat()
+ pid = self.launch_wechat(exe_path)
+ logger.info(f"WeChat launched, PID: {pid}")
+ # 仅传入 PID,触发数据库密钥自动 Hook
+ armed = wx_key.initialize_hook(pid)
- # 仅传入 PID,触发数据库密钥自动 Hook
- if not wx_key.initialize_hook(pid):
+ if not armed:
err = wx_key.get_last_error_msg()
raise RuntimeError(f"数据库 Hook 初始化失败: {err}")
@@ -647,9 +706,21 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。")
key_data = wx_key.poll_key_data()
- if key_data and 'key' in key_data:
- found_db_key = key_data['key']
- break
+ # 注意:wx_key 布防成功后可能先返回"带空 key 的占位结构"(Linux 上
+ # 实测如此),因此必须要求 key 非空;用 `'key' in key_data` 会把空值
+ # 当成结果立刻返回。py_wx_key 自己的 Linux 自测同样是判非空。
+ candidate_key = ""
+ if isinstance(key_data, dict):
+ candidate_key = str(key_data.get("key") or "").strip()
+ if candidate_key:
+ if not re.fullmatch(r"[0-9a-fA-F]{64}", candidate_key):
+ logger.warning(
+ "[db_key] hook 返回了非 64-hex 的候选密钥(len=%s),继续等待",
+ len(candidate_key),
+ )
+ else:
+ found_db_key = candidate_key
+ break
while True:
msg, level = wx_key.get_status_message()
@@ -717,6 +788,24 @@ def get_db_key_workflow(
dict(validation.get("modes") or {}),
)
return result
+ if is_linux():
+ # Linux 只提供 Hook 模式:wx_key 的 fork + TRACEME 免提权路径。
+ # Windows 那套 v4 内存扫描需要 attach 已运行的微信进程,在 Linux 上会被
+ # Yama/ptrace_scope 拦下、必须提权,且稳定性不如 fork 路径,因此不提供
+ # (与 py_wx_key 的 Linux 能力保持一致)。
+ mode = str(key_mode or "auto").strip().lower()
+ if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ raise RuntimeError(
+ "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程),"
+ "请使用 hook 模式。"
+ )
+ if mode not in {"auto", "hook"}:
+ raise RuntimeError(f"未知密钥获取模式: {key_mode}")
+ fetcher = WeChatKeyFetcher()
+ result = fetcher.fetch_db_key(wechat_install_path=wechat_install_path)
+ result["method"] = "hook"
+ return result
+
if not is_windows():
raise RuntimeError("当前平台不支持自动获取数据库密钥,请使用同类工具获取后手动填写。")
@@ -892,6 +981,29 @@ def _get_image_key_kvcomm_dirs(account_dir: Optional[Path] = None) -> tuple[Path
if cursor.parent == cursor:
break
cursor = cursor.parent
+ elif is_linux():
+ # Linux 版微信的 kvcomm 在 ~/.xwechat/net/kvcomm;换网络/重启后会留下
+ # net_1 / net_2 / net_3 … 历史目录,它们同样可能有可用的 code,因此都作为
+ # 候选(当前 net/ 优先,其余按 mtime 从新到旧)。
+ xwechat_root = Path.home() / ".xwechat"
+ candidates = [xwechat_root / "net" / "kvcomm"]
+ try:
+ historical = sorted(
+ (item for item in xwechat_root.glob("net_*") if item.is_dir()),
+ key=lambda item: item.stat().st_mtime_ns,
+ reverse=True,
+ )
+ except OSError:
+ historical = []
+ candidates.extend(item / "kvcomm" for item in historical)
+
+ if account_dir is not None:
+ cursor = Path(account_dir).expanduser()
+ for _ in range(6):
+ candidates.append(cursor / "net" / "kvcomm")
+ if cursor.parent == cursor:
+ break
+ cursor = cursor.parent
else:
appdata = str(os.environ.get("APPDATA") or "").strip()
appdata_root = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
diff --git a/src/wechat_decrypt_tool/native_core_broker.py b/src/wechat_decrypt_tool/native_core_broker.py
index 1a60ca3b..f0e030eb 100644
--- a/src/wechat_decrypt_tool/native_core_broker.py
+++ b/src/wechat_decrypt_tool/native_core_broker.py
@@ -10,6 +10,7 @@
import time
from pathlib import Path
+from .app_paths import get_data_dir
from .native_core_client import (
ENV_NATIVE_CORE_ENDPOINT,
ENV_NATIVE_CORE_LIBRARY,
@@ -117,13 +118,19 @@ def __exit__(self, _exc_type, _exc, _traceback) -> None:
def _broker_name() -> str:
if sys.platform.startswith("win"):
return "wechatdb_broker.exe"
- if sys.platform == "darwin":
+ if sys.platform == "darwin" or sys.platform.startswith("linux"):
return "wechatdb_broker"
- raise NativeCoreComponentMissingError("wechatdb native broker supports Windows and macOS only.")
+ raise NativeCoreComponentMissingError(
+ "wechatdb native broker supports Windows, macOS and Linux only."
+ )
def _client_name() -> str:
- return "wechatdb_client.dll" if sys.platform.startswith("win") else "libwechatdb_client.dylib"
+ if sys.platform.startswith("win"):
+ return "wechatdb_client.dll"
+ if sys.platform.startswith("linux"):
+ return "libwechatdb_client.so"
+ return "libwechatdb_client.dylib"
def _candidate_broker_paths() -> tuple[Path, ...]:
@@ -143,6 +150,8 @@ def _candidate_broker_paths() -> tuple[Path, ...]:
repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / name,
repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / name,
repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / name,
)
)
result: list[Path] = []
@@ -169,10 +178,61 @@ def _new_endpoint() -> str:
token = secrets.token_hex(12)
if sys.platform.startswith("win"):
return rf"\\.\pipe\LifeArchiveProject.WeChatDB.Native.{os.getpid()}.{token}"
- directory = tempfile.gettempdir().rstrip("/\\")
+ directory = os.fspath(_endpoint_directory()).rstrip("/\\")
return f"{directory}/lap-wce-{os.getpid()}-{token}.sock"
+def _endpoint_directory() -> Path:
+ """broker 会校验 socket 所在目录必须"本人拥有 + 组/他人不可写"。
+
+ macOS 的 TMPDIR 天生是 per-user 0700 目录,所以历史上直接用 gettempdir();
+ Linux 的 /tmp 是 sticky + world-writable(任何人都能占位这个 socket 名),
+ 原生侧会直接判 tamper 拒服务,因此优先用 $XDG_RUNTIME_DIR
+ (systemd 登录会话的 /run/user/,0700),缺失时退回一个自建 0700 目录。
+ """
+ if sys.platform == "darwin" or sys.platform.startswith("win"):
+ return Path(tempfile.gettempdir())
+ candidates: list[Path] = []
+ runtime_dir = str(os.environ.get("XDG_RUNTIME_DIR", "") or "").strip()
+ if runtime_dir:
+ candidates.append(Path(runtime_dir))
+ candidates.append(Path(get_data_dir()) / "native-core-run")
+ euid = os.geteuid()
+ for candidate in candidates:
+ # sun_path 只有 108 字节,给 socket 文件名(lap-wce--.sock)留余量。
+ if len(os.fspath(candidate)) > 60:
+ continue
+ try:
+ candidate.mkdir(parents=True, exist_ok=True)
+ os.chmod(candidate, 0o700)
+ status = candidate.stat()
+ except OSError:
+ continue
+ if status.st_uid != euid or (status.st_mode & 0o022) != 0:
+ continue
+ if (status.st_mode & 0o300) != 0o300:
+ continue
+ return candidate
+ raise NativeCoreUnavailableError(
+ "Cannot locate a private directory for the native core broker socket."
+ )
+
+
+def _unlink_unix_socket(endpoint: str) -> None:
+ """清理 unix socket 与它的 flock 锁文件(broker 用 .lock 互斥)。
+
+ 只应在确认 broker 进程已退出后调用(否则会破坏原生侧的单实例互斥)。
+ Windows 用的是命名管道,没有文件系统路径要删。
+ """
+ if not endpoint or sys.platform.startswith("win"):
+ return
+ for suffix in ("", ".lock"):
+ try:
+ Path(endpoint + suffix).unlink(missing_ok=True)
+ except OSError:
+ continue
+
+
def _startup_timeout_seconds() -> float:
default_timeout_ms = (
"60000" if sys.platform == "darwin" or sys.platform.startswith("win") else "5000"
@@ -521,8 +581,9 @@ def ensure_native_core_broker(
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
- if sys.platform == "darwin":
- Path(endpoint).unlink(missing_ok=True)
+ # broker 用的是 unix socket(macOS/Linux),失败路径也要清掉这个 socket 文件,
+ # 否则下次启动会撞上残留路径。Windows 是命名管道,没有文件要清。
+ _unlink_unix_socket(endpoint)
if isinstance(exc, NativeCoreUnavailableError) and log_path is not None:
tail = _broker_log_tail(log_path, log_start_offset)
detail = f" Broker log: {log_path}."
@@ -594,8 +655,7 @@ def stop_native_core_broker(*, _force: bool = False) -> None:
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
- if endpoint and sys.platform == "darwin":
- Path(endpoint).unlink(missing_ok=True)
+ _unlink_unix_socket(endpoint)
atexit.register(stop_native_core_broker, _force=True)
diff --git a/src/wechat_decrypt_tool/native_core_client.py b/src/wechat_decrypt_tool/native_core_client.py
index 1c3234b8..f61cf554 100644
--- a/src/wechat_decrypt_tool/native_core_client.py
+++ b/src/wechat_decrypt_tool/native_core_client.py
@@ -545,11 +545,19 @@ class NativeCoreBuildManifest:
source_runtime: bool = False
windows_host_verification: str = ""
macos_host_verification: str = ""
+ linux_client_sha256: bytes = field(default=b"\0" * 32, repr=False)
+ linux_broker_sha256: bytes = field(default=b"\0" * 32, repr=False)
+ linux_integrity_mode: str = ""
+ linux_peer_verification: str = ""
+ linux_host_verification: str = ""
@property
def client_signer_sha256(self) -> bytes:
if self.platform == "macos":
return self.macos_client_signer_sha256
+ if self.platform == "linux":
+ # Linux 没有签名者,身份即 client 的内容哈希 pin。
+ return self.linux_client_sha256
return self.windows_client_signer_sha256
@@ -951,7 +959,13 @@ def _load_native_core_build_manifest(
root_public_key_compiled = payload.get("rootPublicKeyCompiled")
test_hooks_enabled = payload.get("testHooksEnabled")
staging_pinned_signer_trust = payload.get("stagingPinnedSignerTrust")
- manifest_platform = "macos" if schema_version == 3 else "windows"
+ # schema 2 = Windows(历史形态,不带 platform 字段)、3 = macOS、4 = Linux。
+ if schema_version == 4:
+ manifest_platform = "linux"
+ elif schema_version == 3:
+ manifest_platform = "macos"
+ else:
+ manifest_platform = "windows"
windows_client_signer_sha256 = payload.get("windowsClientSignerSha256")
offline_bootstrap_feature_bits_value = payload.get(
"offlineBootstrapFeatureBits"
@@ -965,7 +979,7 @@ def _load_native_core_build_manifest(
offline_export_seal_format = payload.get("offlineExportSealFormat")
distribution_mode_value = payload.get("distributionMode")
distribution_capsule_value = payload.get("distributionCapsule")
- if type(schema_version) is not int or schema_version not in {2, 3}:
+ if type(schema_version) is not int or schema_version not in {2, 3, 4}:
raise NativeCoreProtocolError(
"wechatdb native build manifest has an unsupported schemaVersion."
)
@@ -973,6 +987,10 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"wechatdb native schemaVersion 3 requires platform macos."
)
+ if schema_version == 4 and payload.get("platform") != "linux":
+ raise NativeCoreProtocolError(
+ "wechatdb native schemaVersion 4 requires platform linux."
+ )
if schema_version == 2 and "platform" in payload:
raise NativeCoreProtocolError(
"wechatdb native schemaVersion 2 must not declare a platform."
@@ -985,7 +1003,9 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"Windows wechatdb native build manifest must declare readOnlyBuild=true and no WeChat actions."
)
- if schema_version == 3:
+ # macOS(v3) 与 Linux(v4) 的 manifest 不带 readOnlyBuild 字段:两者恒为只读 runtime,
+ # 不能让它留成 None(None 会让后面的判定链静默短路成 None)。
+ if schema_version in {3, 4}:
read_only_build = True
source_runtime = False
windows_host_verification = ""
@@ -1038,6 +1058,129 @@ def _load_native_core_build_manifest(
)
source_runtime = True
macos_host_verification = "same-user-direct-parent"
+ linux_client_sha256 = bytes(32)
+ linux_broker_sha256 = bytes(32)
+ linux_integrity_mode = ""
+ linux_peer_verification = ""
+ linux_host_verification = ""
+ if schema_version == 4:
+ # Linux 没有代码签名 / 签名者证书,身份由两组内容哈希 pin 承担:
+ # broker 钉 client 的文件 SHA-256(单向,build 脚本两遍构建保证可解),
+ # 进程间再靠 SO_PEERCRED + 直接父进程关系互认。
+ foreign_fields = (
+ "windowsClientSignerSha256",
+ "windowsBrokerSignerSha256",
+ "windowsPrivateRootSha256",
+ "windowsSignerTrustMode",
+ "windowsPrivatePkiLeafRevocation",
+ "windowsHostVerification",
+ "macosClientSignerSha256",
+ "macosBrokerSignerSha256",
+ "macosHostSignerSha256",
+ "macosPrivateRootSha256",
+ "macosClientSigningIdentifier",
+ "macosBrokerSigningIdentifier",
+ "macosHostSigningIdentifier",
+ "macosSigningMode",
+ "macosSignerTrustMode",
+ "macosPrivatePkiLeafRevocation",
+ "macosHostVerification",
+ )
+ declared = sorted(name for name in foreign_fields if name in payload)
+ if declared:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must not declare Windows or macOS "
+ "signing fields: " + ", ".join(declared)
+ )
+ if "linuxHostVerification" not in payload:
+ raise NativeCoreProtocolError(
+ "Linux native manifests must declare linuxHostVerification."
+ )
+ if "sourceRuntime" in payload and payload.get("sourceRuntime") is not True:
+ raise NativeCoreProtocolError(
+ "Linux source-runtime manifests must declare sourceRuntime=true."
+ )
+ linux_integrity_mode = payload.get("linuxIntegrityMode")
+ if linux_integrity_mode not in {"content-hash-pin", "development"}:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare linuxIntegrityMode "
+ "content-hash-pin or development."
+ )
+ if development_build != (linux_integrity_mode == "development"):
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must pair the development integrity "
+ "mode with developmentBuild."
+ )
+ linux_pin_values = (
+ payload.get("linuxClientSha256"),
+ payload.get("linuxBrokerSha256"),
+ )
+ if any(
+ not isinstance(value, str)
+ or re.fullmatch(r"[0-9a-f]{64}", value) is None
+ for value in linux_pin_values
+ ):
+ raise NativeCoreProtocolError(
+ "wechatdb native build manifest contains invalid Linux content-hash pins."
+ )
+ linux_client_sha256, linux_broker_sha256 = (
+ bytes.fromhex(value) for value in linux_pin_values
+ )
+ if development_build and (
+ any(linux_client_sha256) or any(linux_broker_sha256)
+ ):
+ raise NativeCoreProtocolError(
+ "Development Linux native builds must not carry production content-hash pins."
+ )
+ if not development_build and not (
+ any(linux_client_sha256) and any(linux_broker_sha256)
+ ):
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native content-hash pins must be non-zero."
+ )
+ linux_peer_verification = payload.get("linuxPeerVerification")
+ if linux_peer_verification != "same-user-peer-credentials":
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare the same-user peer "
+ "credential policy."
+ )
+ linux_host_verification = payload.get("linuxHostVerification")
+ if linux_host_verification not in {
+ "content-hash-pin",
+ "same-user-direct-parent",
+ }:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare a supported host "
+ "verification policy."
+ )
+ # 三份 profile:development(developmentBuild,无 sourceRuntime)、
+ # production(无 sourceRuntime,宿主校验=内容哈希)、
+ # source-public(sourceRuntime=true,宿主校验=直接父进程)。
+ source_runtime = bool(payload.get("sourceRuntime"))
+ if not development_build and (
+ linux_host_verification == "same-user-direct-parent"
+ ) != source_runtime:
+ raise NativeCoreProtocolError(
+ "Linux host verification must be paired with sourceRuntime outside "
+ "development builds."
+ )
+ if source_runtime and linux_integrity_mode != "content-hash-pin":
+ raise NativeCoreProtocolError(
+ "Linux source-runtime manifests must keep the production integrity mode."
+ )
+ if schema_version in {2, 3} and any(
+ name in payload
+ for name in (
+ "linuxIntegrityMode",
+ "linuxClientSha256",
+ "linuxBrokerSha256",
+ "linuxPeerVerification",
+ "linuxHostVerification",
+ )
+ ):
+ raise NativeCoreProtocolError(
+ "Only Linux wechatdb native manifests may declare Linux integrity fields."
+ )
if (
not isinstance(build_id, str)
or not _NATIVE_CORE_BUILD_ID_PATTERN.fullmatch(build_id)
@@ -1091,6 +1234,9 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"wechatdb native build manifest contains an invalid windowsClientSignerSha256."
)
+ elif manifest_platform == "linux":
+ # Linux 没有签名者证书;承载"客户端身份"的就是内容哈希 pin。
+ signer_digest = linux_client_sha256
else:
signer_digest = bytes(32)
macos_identifiers = (
@@ -1315,6 +1461,11 @@ def _load_native_core_build_manifest(
source_runtime=source_runtime,
windows_host_verification=windows_host_verification,
macos_host_verification=macos_host_verification,
+ linux_client_sha256=linux_client_sha256,
+ linux_broker_sha256=linux_broker_sha256,
+ linux_integrity_mode=linux_integrity_mode,
+ linux_peer_verification=linux_peer_verification,
+ linux_host_verification=linux_host_verification,
)
@@ -1333,6 +1484,32 @@ def _required_native_core_build_manifest(
"wechatdb native build manifest does not match the current platform."
)
frozen = bool(getattr(sys, "frozen", False))
+ if manifest.platform == "linux":
+ # Linux 目前没有安装包/冻结应用(release.yml 只做 Windows/macOS),所以唯一
+ # 受支持的消费方式是公开源码 checkout 使用 source-public native core——
+ # 与 Windows/macOS "源码 checkout 只接受受限 source-public" 同一原则。
+ if not frozen and _is_source_public_native_core_build_manifest(manifest):
+ from .native_core_lease import validate_native_core_authorization_policy
+
+ validate_native_core_authorization_policy(manifest)
+ return manifest
+ if frozen and manifest.source_runtime:
+ raise NativeCoreProtocolError(
+ "Frozen WeChatDataAnalysis rejects the source-public Linux native core."
+ )
+ if not frozen:
+ raise NativeCoreProtocolError(
+ "Source WeChatDataAnalysis on Linux requires the exact restricted "
+ "source-public native core."
+ )
+ if _is_production_native_core_build_manifest(manifest):
+ from .native_core_lease import validate_native_core_authorization_policy
+
+ validate_native_core_authorization_policy(manifest)
+ return manifest
+ raise NativeCoreProtocolError(
+ "Frozen WeChatDataAnalysis requires a production wechatdb native core."
+ )
if manifest.platform == "macos":
if (
frozen
@@ -1439,6 +1616,11 @@ def _is_production_native_core_build_manifest_base(
== _NATIVE_CORE_OFFLINE_BOOTSTRAP_FEATURES
and manifest.offline_export_seal_format == "WES2"
and not _NATIVE_CORE_NON_PRODUCTION_BUILD_ID_PATTERN.search(manifest.build_id)
+ # Linux 用内容哈希 pin 代替签名者摘要,但 manifest 必须声明签发态。
+ and (
+ manifest.platform != "linux"
+ or manifest.linux_integrity_mode == "content-hash-pin"
+ )
)
@@ -1490,6 +1672,8 @@ def _is_source_public_native_core_build_manifest(
return False
if manifest.platform == "macos":
return manifest.macos_host_verification == "same-user-direct-parent"
+ if manifest.platform == "linux":
+ return manifest.linux_host_verification == "same-user-direct-parent"
return (
manifest.platform == "windows"
and manifest.windows_host_verification == "same-user-direct-parent"
@@ -1501,8 +1685,10 @@ def _manifest_matches_runtime_platform(
runtime_platform: str | None = None,
) -> bool:
current = sys.platform if runtime_platform is None else runtime_platform
- return (current.startswith("win") and manifest.platform == "windows") or (
- current == "darwin" and manifest.platform == "macos"
+ return (
+ (current.startswith("win") and manifest.platform == "windows")
+ or (current == "darwin" and manifest.platform == "macos")
+ or (current.startswith("linux") and manifest.platform == "linux")
)
@@ -1534,7 +1720,11 @@ def _native_library_name() -> str:
return "wechatdb_client.dll"
if sys.platform == "darwin":
return "libwechatdb_client.dylib"
- raise NativeCoreComponentMissingError("wechatdb native core supports Windows and macOS only.")
+ if sys.platform.startswith("linux"):
+ return "libwechatdb_client.so"
+ raise NativeCoreComponentMissingError(
+ "wechatdb native core supports Windows, macOS and Linux only."
+ )
def _candidate_library_paths() -> tuple[Path, ...]:
@@ -1559,6 +1749,8 @@ def _candidate_library_paths() -> tuple[Path, ...]:
repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / file_name,
repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / file_name,
repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / file_name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / file_name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / file_name,
)
)
@@ -1589,17 +1781,18 @@ def resolve_native_core_library() -> Path:
def _native_core_broker_name() -> str:
if sys.platform.startswith("win"):
return "wechatdb_broker.exe"
- if sys.platform == "darwin":
+ # macOS 与 Linux 共用同一个可执行文件名(两者都是 ELF/Mach-O 裸二进制)。
+ if sys.platform == "darwin" or sys.platform.startswith("linux"):
return "wechatdb_broker"
raise NativeCoreComponentMissingError(
- "wechatdb native broker supports Windows and macOS only."
+ "wechatdb native broker supports Windows, macOS and Linux only."
)
def _native_core_entrypoint_directory() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent / "native"
- if sys.platform in {"darwin", "win32"}:
+ if sys.platform in {"darwin", "win32"} or sys.platform.startswith("linux"):
configured = str(os.environ.get(ENV_SOURCE_NATIVE_CORE_DIR, "") or "").strip()
if configured:
try:
diff --git a/src/wechat_decrypt_tool/native_core_device_credential.py b/src/wechat_decrypt_tool/native_core_device_credential.py
index d7d2c53b..0bf48123 100644
--- a/src/wechat_decrypt_tool/native_core_device_credential.py
+++ b/src/wechat_decrypt_tool/native_core_device_credential.py
@@ -12,6 +12,10 @@
from pathlib import Path
from typing import Callable
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
from .app_paths import get_data_dir
from .native_core_client import NativeCoreProtocolError, NativeCoreUnavailableError
@@ -27,6 +31,12 @@
_ENTROPY_DOMAIN = b"WeChatDataAnalysis/native-core/device-credential/v2\0"
_MACOS_KEYCHAIN_MAGIC = b"WCEKC002"
_MACOS_KEYCHAIN_SERVICE = "com.lifearchive.wechatdataanalysis.native-core-credential.v2"
+# Linux 没有系统级 per-user keystore(DPAPI/Keychain 对应物),所以采用两种
+# Unix 惯例的组合:文件 0600(等同 SSH 私钥的卫生标准)+ 用 machine-id + uid
+# 派生密钥的 AEAD 信封(这样把文件拷到另一台机器/另一个用户下也解不开)。
+_LINUX_MAGIC = b"WCELDC1"
+_LINUX_AAD = b"WeChatDataAnalysis/native-core/device-credential/linux/v1"
+_LINUX_NONCE_BYTES = 12
CredentialTransform = Callable[[bytes, bytes], bytes]
BytesLike = bytes | bytearray | memoryview
@@ -203,6 +213,30 @@ def _parse_record(plaintext: bytes, *, expected_schema: int) -> StoredDeviceCred
raise NativeCoreProtocolError("Native core device credential is invalid.")
+def _linux_machine_identity() -> bytes:
+ """machine-id + uid:把凭据绑定到"这台机器上的这个用户"。"""
+ for candidate in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
+ try:
+ value = Path(candidate).read_text(encoding="ascii").strip()
+ except OSError:
+ continue
+ if value:
+ return f"{value}:{os.getuid()}".encode("utf-8")
+ raise NativeCoreUnavailableError(
+ "Cannot determine the Linux machine identity for the native core device credential."
+ )
+
+
+def _linux_credential_key(entropy: bytes) -> bytes:
+ """entropy 作为 salt/AAD 绑定 device/build/service,拷到别处失效。"""
+ return HKDF(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=entropy,
+ info=_LINUX_AAD,
+ ).derive(_linux_machine_identity())
+
+
def _protect_current_user(payload: bytes, entropy: bytes) -> bytes:
if sys.platform == "darwin":
account_digest = hashlib.sha256(
@@ -235,8 +269,15 @@ def _protect_current_user(payload: bytes, entropy: bytes) -> bytes:
from .native_core_raw_key_cache import _dpapi_transform
return _dpapi_transform(payload, entropy=entropy, protect=True)
+ if sys.platform.startswith("linux"):
+ nonce = os.urandom(_LINUX_NONCE_BYTES)
+ sealed = AESGCM(_linux_credential_key(entropy)).encrypt(
+ nonce, payload, _LINUX_AAD
+ )
+ return _LINUX_MAGIC + nonce + sealed
raise NativeCoreUnavailableError(
- "Native core device credentials require Windows DPAPI or macOS Keychain."
+ "Native core device credentials require Windows DPAPI, macOS Keychain or "
+ "the Linux machine-bound credential store."
)
@@ -288,8 +329,30 @@ def _unprotect_current_user(payload: bytes, entropy: bytes) -> bytes:
from .native_core_raw_key_cache import _dpapi_transform
return _dpapi_transform(payload, entropy=entropy, protect=False)
+ if sys.platform.startswith("linux"):
+ offset = len(_LINUX_MAGIC)
+ if (
+ len(payload) <= offset + _LINUX_NONCE_BYTES
+ or not payload.startswith(_LINUX_MAGIC)
+ ):
+ raise NativeCoreProtocolError(
+ "Native core Linux credential binding is invalid."
+ )
+ nonce = payload[offset : offset + _LINUX_NONCE_BYTES]
+ try:
+ return AESGCM(_linux_credential_key(entropy)).decrypt(
+ nonce, payload[offset + _LINUX_NONCE_BYTES :], _LINUX_AAD
+ )
+ except Exception as exc:
+ # 解不开通常意味着换机器/换用户/换 device-build-service 绑定,
+ # 与 macOS Keychain 不一致的情形等价:当作凭据失效处理。
+ raise NativeCoreProtocolError(
+ "Native core Linux device credential cannot be decrypted on this "
+ "machine or user."
+ ) from exc
raise NativeCoreUnavailableError(
- "Native core device credentials require Windows DPAPI or macOS Keychain."
+ "Native core device credentials require Windows DPAPI, macOS Keychain or "
+ "the Linux machine-bound credential store."
)
diff --git a/src/wechat_decrypt_tool/native_core_lease.py b/src/wechat_decrypt_tool/native_core_lease.py
index ef8d20df..c513db16 100644
--- a/src/wechat_decrypt_tool/native_core_lease.py
+++ b/src/wechat_decrypt_tool/native_core_lease.py
@@ -48,6 +48,7 @@
_PRODUCTION_APP_IDS = {
"windows": "wechat-data-analysis.windows",
"macos": "wechat-data-analysis.macos",
+ "linux": "wechat-data-analysis.linux",
}
_LICENSE_PROTOCOL_VERSION = 2
_MAX_RESPONSE_BYTES = 64 * 1024
diff --git a/src/wechat_decrypt_tool/platform_support.py b/src/wechat_decrypt_tool/platform_support.py
index 32c0d871..5c310f86 100644
--- a/src/wechat_decrypt_tool/platform_support.py
+++ b/src/wechat_decrypt_tool/platform_support.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import importlib.util
import json
import os
import platform
@@ -33,6 +34,10 @@ def is_windows() -> bool:
return current_platform() == "windows"
+def is_linux() -> bool:
+ return current_platform() == "linux"
+
+
def _native_root() -> Path:
return Path(__file__).resolve().parent / "native"
@@ -133,6 +138,39 @@ def mac_native_core_paths() -> tuple[Path, Path, Path]:
)
+def linux_native_core_paths() -> tuple[Path, Path, Path]:
+ """Linux 的 client 是 .so,broker 与 macOS 同名(同为裸可执行文件)。"""
+ return (
+ _first_existing_native_resource(
+ Path("libwechatdb_client.so"),
+ explicit=str(
+ os.environ.get("WECHAT_TOOL_NATIVE_CORE_LIBRARY", "") or ""
+ ).strip(),
+ ),
+ _first_existing_native_resource(
+ Path("wechatdb_broker"),
+ explicit=str(
+ os.environ.get("WECHAT_TOOL_NATIVE_CORE_BROKER", "") or ""
+ ).strip(),
+ ),
+ _first_existing_native_resource(Path("wechatdb_native_build.json")),
+ )
+
+
+def _linux_native_core_manifest_ready(manifest: dict[str, Any]) -> bool:
+ """Linux 只认 source-public profile(与 Windows/macOS 同一原则)。
+
+ 必须与 native_core_client 的授权策略保持一致:那边会拒掉 production
+ profile,这里就不能报"可用",否则界面说可用、一调用就报错。
+ """
+ return (
+ manifest.get("linuxIntegrityMode") == "content-hash-pin"
+ and manifest.get("linuxPeerVerification") == "same-user-peer-credentials"
+ and manifest.get("sourceRuntime") is True
+ and manifest.get("linuxHostVerification") == "same-user-direct-parent"
+ )
+
+
def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
client, broker, manifest_path = paths
try:
@@ -153,6 +191,8 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
return False
if manifest.get("schemaVersion") == 2 and "platform" not in manifest:
return True
+ if manifest.get("schemaVersion") == 4 and manifest.get("platform") == "linux":
+ return _linux_native_core_manifest_ready(manifest)
if manifest.get("schemaVersion") != 3 or manifest.get("platform") != "macos":
return False
source_fields = {
@@ -167,13 +207,31 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
)
+def _linux_wx_key_available() -> bool:
+ """Linux 的密钥获取全部依赖 wx_key(hook 模式,由它 fork 拉起微信)。
+
+ 不 import,只用 find_spec 探测,避免能力查询带起原生模块加载。
+ """
+ try:
+ return importlib.util.find_spec("wx_key") is not None
+ except (ImportError, ValueError):
+ return False
+
+
def runtime_capabilities() -> dict[str, Any]:
system = current_platform()
architecture = (platform.machine() or "unknown").lower()
apple_silicon = system == "macos" and architecture in {"arm64", "aarch64"}
+ linux_key_ready = system == "linux" and _linux_wx_key_available()
helper = mac_image_scan_helper_path() if system == "macos" else None
image_scan_library = mac_image_scan_library_path() if system == "macos" else None
- native_core_paths = mac_native_core_paths() if system == "macos" else None
+ native_core_paths = (
+ mac_native_core_paths()
+ if system == "macos"
+ else linux_native_core_paths()
+ if system == "linux"
+ else None
+ )
image_scan_ready = bool(
helper
and image_scan_library
@@ -181,8 +239,9 @@ def runtime_capabilities() -> dict[str, Any]:
and image_scan_library.is_file()
and helper.parent.resolve() == image_scan_library.parent.resolve()
)
+ # macOS 的实时 WCDB 仅支持 Apple Silicon;Linux 没有架构门槛(x86_64 基线)。
realtime_ready = bool(
- apple_silicon
+ (system != "macos" or apple_silicon)
and native_core_paths
and _native_core_resources_ready(native_core_paths)
)
@@ -209,7 +268,11 @@ def runtime_capabilities() -> dict[str, Any]:
"platform_release": platform.release(),
"architecture": architecture,
"apple_silicon": apple_silicon,
- "database_key_extraction": system == "windows" or bool(mac_db_key_status["available"]),
+ "database_key_extraction": (
+ system == "windows"
+ or bool(mac_db_key_status["available"])
+ or linux_key_ready
+ ),
"macos_lldb_fallback": macos_lldb_fallback,
"macos_lldb_fallback_note": (
"实验性本机调试兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。"
@@ -218,10 +281,12 @@ def runtime_capabilities() -> dict[str, Any]:
),
"database_key_manual_input": True,
"database_decryption": True,
- "image_key_memory_scan": system == "windows" or image_scan_ready,
+ "image_key_memory_scan": system == "windows" or image_scan_ready or linux_key_ready,
"image_key_memory_scan_note": (
"macOS 图片密钥扫描原生资源缺失或安装不完整,请重新安装完整发行包。"
if system == "macos" and not image_scan_ready
+ else "Linux 图片密钥获取依赖 wx_key(本地算法),未检测到该模块。"
+ if system == "linux" and not linux_key_ready
else ""
),
"realtime_wcdb": system == "windows" or realtime_ready,
@@ -230,6 +295,8 @@ def runtime_capabilities() -> dict[str, Any]:
if system == "macos" and not apple_silicon
else "macOS 实时 WCDB 原生资源缺失,请重新安装完整发行包。"
if system == "macos" and not realtime_ready
+ else "Linux 实时 WCDB 需要受限 source-public 原生组件(内容哈希 pin + 构建有效期),组件缺失或不是该 profile 时就不可用。"
+ if system == "linux" and not realtime_ready
else ""
),
"wechat_process_media_hook": system == "windows",
@@ -239,6 +306,11 @@ def runtime_capabilities() -> dict[str, Any]:
"database_key_guidance": (
str(mac_db_key_status.get("note") or MAC_DB_KEY_GUIDANCE)
if system == "macos"
+ else "Linux 取密钥时会由 wx_key 拉起微信(免提权,走 fork + TRACEME),"
+ "请在弹出的微信里完成登录;Linux 不提供 V4 内存扫描(需要提权 attach),"
+ "取密钥只有 Hook 一条路。程序不能以 root 运行,否则 AppImage 版微信"
+ "会因 FUSE 对 root 不可见而打不开窗口。"
+ if system == "linux"
else ""
),
"database_key_build_id": (
@@ -255,11 +327,13 @@ def runtime_capabilities() -> dict[str, Any]:
__all__ = [
"MAC_DB_KEY_GUIDANCE",
"current_platform",
+ "is_linux",
"is_macos",
"is_windows",
"mac_image_scan_helper_path",
"mac_image_scan_library_path",
"mac_db_key_bundle_dir",
"mac_native_core_paths",
+ "linux_native_core_paths",
"runtime_capabilities",
]
diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py
index 3e9b1a4f..b8bfa48f 100644
--- a/src/wechat_decrypt_tool/routers/keys.py
+++ b/src/wechat_decrypt_tool/routers/keys.py
@@ -28,7 +28,7 @@
)
from ..media_helpers import _load_media_keys, _resolve_account_dir
from ..path_fix import PathFixRoute
-from ..platform_support import current_platform, is_macos, runtime_capabilities
+from ..platform_support import current_platform, is_macos, is_windows, runtime_capabilities
router = APIRouter(route_class=PathFixRoute)
logger = get_logger(__name__)
@@ -573,7 +573,10 @@ async def watch_disconnect() -> None:
},
}
mode = str(key_mode or "auto").strip().lower()
- if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权),
+ # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回
+ # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。
+ if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}:
return {
"status": -2,
"errmsg": f"扫内存失败: {str(e)}",
@@ -613,7 +616,10 @@ async def watch_disconnect() -> None:
},
}
mode = str(key_mode or "auto").strip().lower()
- if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权),
+ # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回
+ # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。
+ if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}:
return {
"status": -2,
"errmsg": f"扫内存失败: {str(e)}",
diff --git a/src/wechat_decrypt_tool/wcdb_realtime.py b/src/wechat_decrypt_tool/wcdb_realtime.py
index 64bde3dd..07d8cab7 100644
--- a/src/wechat_decrypt_tool/wcdb_realtime.py
+++ b/src/wechat_decrypt_tool/wcdb_realtime.py
@@ -475,7 +475,12 @@ def get_status(self, account_dir: Path) -> dict[str, Any]:
client_path = native_dir / "wechatdb_client.dll"
broker_path = native_dir / "wechatdb_broker.exe"
else:
- client_path = native_dir / "libwechatdb_client.dylib"
+ client_path = native_dir / (
+ "libwechatdb_client.so"
+ if sys.platform.startswith("linux")
+ else "libwechatdb_client.dylib"
+ )
+ # macOS 与 Linux 的 broker 可执行文件名相同。
broker_path = native_dir / "wechatdb_broker"
manifest_path = client_path.with_name("wechatdb_native_build.json")
components_present = all(
diff --git a/src/wechat_decrypt_tool/wechat_detection.py b/src/wechat_decrypt_tool/wechat_detection.py
index bda63d25..f0ad5940 100644
--- a/src/wechat_decrypt_tool/wechat_detection.py
+++ b/src/wechat_decrypt_tool/wechat_detection.py
@@ -356,7 +356,22 @@ def get_process_list():
def _wechat_process_targets() -> set[str]:
- return {"wechat"} if sys.platform == "darwin" else {"weixin.exe", "wechat.exe"}
+ if sys.platform == "darwin":
+ return {"wechat"}
+ if sys.platform.startswith("linux"):
+ # Linux 版微信的进程名就是 wechat(AppImage 解包后同样如此)。
+ return {"wechat", "wechat-bin"}
+ return {"weixin.exe", "wechat.exe"}
+
+
+# Linux 微信可执行文件的标准位置:发行版包是 /usr/bin/wechat(符号链接到
+# /opt/wechat/wechat),手工安装可能在 ~/.local/bin。
+_LINUX_WECHAT_EXECUTABLE_PATHS = (
+ "/usr/bin/wechat",
+ "/opt/wechat/wechat",
+ "/usr/local/bin/wechat",
+ "~/.local/bin/wechat",
+)
def _is_wechat_dir_candidate_name(name: str) -> bool:
@@ -444,6 +459,18 @@ def add(path_value: str | None) -> None:
add(str(container_root / "Documents" / "xwechat_files"))
return scan_paths
+ if sys.platform.startswith("linux"):
+ # Linux 版微信 4.x 的数据落在“文档目录”下的 xwechat_files//db_storage。
+ # 除 XDG 文档目录外,也兼容解包目录/自定义安装把数据放到家目录或
+ # ~/.local/share 的情形。
+ add(os.path.join(home_dir, "Documents", "xwechat_files"))
+ add(os.path.join(home_dir, "xwechat_files"))
+ add(os.path.join(home_dir, ".local", "share", "xwechat_files"))
+ xdg_documents = str(os.environ.get("XDG_DOCUMENTS_DIR") or "").strip()
+ if xdg_documents:
+ add(os.path.join(xdg_documents, "xwechat_files"))
+ return scan_paths
+
user_profile = str(os.environ.get("USERPROFILE") or "").strip()
if user_profile:
add(user_profile)
@@ -1089,7 +1116,11 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
# 尝试获取版本信息
try:
- if sys.platform == "darwin":
+ if sys.platform.startswith("linux"):
+ # Linux 上没有 PE 版本资源/Info.plist 可读,版本号只用于
+ # 展示,留空即可(不要走到 win32api 那支去制造噪音)。
+ version = ""
+ elif sys.platform == "darwin":
info_plist = Path(result["wechat_install_path"]) / "Contents" / "Info.plist"
with info_plist.open("rb") as stream:
info = plistlib.load(stream)
@@ -1128,6 +1159,19 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
except (OSError, ValueError):
pass
break
+ elif sys.platform.startswith("linux"):
+ # 未运行时按标准位置兜底;/usr/bin/wechat 是符号链接,resolve() 后
+ # 取父目录就是真正的安装目录(例如 /opt/wechat)。
+ for candidate in _LINUX_WECHAT_EXECUTABLE_PATHS:
+ executable = Path(candidate).expanduser()
+ if not executable.is_file():
+ continue
+ result["wechat_exe_path"] = str(executable)
+ result["wechat_install_path"] = str(executable.resolve().parent)
+ result["detection_methods"].append(
+ f"标准位置检测到微信: {executable}"
+ )
+ break
# 2. 使用新的账号检测逻辑:同时支持 Backup 与登录信息目录,并合并结果
result["detection_methods"].append("多账户检测(多来源合并)")
diff --git a/tests/test_linux_db_key_flow.py b/tests/test_linux_db_key_flow.py
new file mode 100644
index 00000000..475e3581
--- /dev/null
+++ b/tests/test_linux_db_key_flow.py
@@ -0,0 +1,98 @@
+import asyncio
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from wechat_decrypt_tool import key_service
+from wechat_decrypt_tool.routers import keys as keys_router
+
+
+V4_MODE_ERROR = "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程),请使用 hook 模式。"
+
+
+def _as_linux(test_case) -> None:
+ """把平台判定固定成 Linux,使断言不依赖跑测试的机器。"""
+ for entry in (
+ patch.object(key_service, "is_macos", return_value=False),
+ patch.object(key_service, "is_linux", return_value=True),
+ patch.object(key_service, "is_windows", return_value=False),
+ patch.object(keys_router, "is_macos", return_value=False),
+ # keys 路由没有导入 is_linux:它只区分「是不是 macOS」与「是不是 Windows」。
+ patch.object(keys_router, "is_windows", return_value=False),
+ ):
+ test_case.enterContext(entry)
+
+
+class TestLinuxDbKeyFlow(unittest.TestCase):
+ def test_linux_key_v4_request_never_offers_a_hook_fallback_dialog(self) -> None:
+ """Linux 没有 V4 内存扫描:不得回报 can_fallback_to_hook。
+
+ 该字段是前端「内存扫描失败,是否改用 Hook?」弹窗的唯一触发条件;Linux 的
+ Hook(fork + TRACEME)并不需要这种两段式兜底,回报它会让用户看到一次
+ 永远不可能成功的引导。
+ """
+ _as_linux(self)
+ with patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError(V4_MODE_ERROR)):
+ result = asyncio.run(
+ keys_router.get_wechat_db_key(
+ request=None,
+ db_storage_path="/tmp/db_storage",
+ key_mode="key_v4",
+ )
+ )
+
+ self.assertEqual(result["status"], -1)
+ self.assertNotIn("can_fallback_to_hook", result["data"])
+ self.assertIn("hook", result["errmsg"])
+
+ def test_windows_key_v4_request_keeps_the_hook_fallback_dialog(self) -> None:
+ """Windows 的两段式流程必须保持不变。"""
+ with (
+ patch.object(keys_router, "is_macos", return_value=False),
+ patch.object(keys_router, "is_windows", return_value=True),
+ patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError("scan failed")),
+ ):
+ result = asyncio.run(
+ keys_router.get_wechat_db_key(
+ request=None,
+ db_storage_path="D:/xwechat_files/wxid/db_storage",
+ key_mode="key_v4",
+ )
+ )
+
+ self.assertEqual(result["status"], -2)
+ self.assertTrue(result["data"]["can_fallback_to_hook"])
+ self.assertEqual(result["data"]["method"], "key_v4")
+
+ def test_linux_key_v4_mode_is_rejected_before_any_memory_scan(self) -> None:
+ """core 层也必须拒绝 v4:Linux 只有 hook 一条路。"""
+ _as_linux(self)
+ with self.assertRaises(RuntimeError) as context:
+ key_service.get_db_key_workflow(key_mode="key_v4")
+
+ self.assertIn("hook", str(context.exception))
+
+ def test_linux_auto_mode_goes_straight_to_hook(self) -> None:
+ _as_linux(self)
+ fetcher = MagicMock()
+ fetcher.fetch_db_key.return_value = {"db_key": "a" * 64}
+ with patch.object(key_service, "WeChatKeyFetcher", return_value=fetcher):
+ result = key_service.get_db_key_workflow(key_mode="auto")
+
+ fetcher.fetch_db_key.assert_called_once()
+ self.assertEqual(result["method"], "hook")
+ self.assertEqual(result["db_key"], "a" * 64)
+
+ def test_linux_unknown_mode_is_rejected(self) -> None:
+ _as_linux(self)
+ with self.assertRaises(RuntimeError):
+ key_service.get_db_key_workflow(key_mode="not-a-mode")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_linux_db_key_frontend.py b/tests/test_linux_db_key_frontend.py
new file mode 100644
index 00000000..f4cfe4a7
--- /dev/null
+++ b/tests/test_linux_db_key_frontend.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_frontend(path: str) -> str:
+ return (ROOT / "frontend" / path).read_text(encoding="utf-8")
+
+
+def _linux_hook_branch(source: str) -> str:
+ """截出 handleGetDbKey 里 Linux 的那一段分支(不含后续 Windows 分支)。"""
+ branch = source.split("if (isLinux.value) {", 1)[1]
+ return branch.split("} else if (dbStoragePath) {", 1)[0]
+
+
+def test_decrypt_page_skips_v4_memory_scan_on_linux() -> None:
+ """Linux 取密钥不尝试内存扫描:直接走 Hook,也不提示「内存扫描失败」。"""
+ source = read_frontend("pages/decrypt.vue")
+
+ linux_branch = _linux_hook_branch(source)
+ assert "await fetchByHook()" in linux_branch
+ # 不给后端发 V4 请求('key_v4' 才是请求体里的字面量),也不需要数据库路径来验证候选。
+ assert "'key_v4'" not in linux_branch
+ assert "dbStoragePath" not in linux_branch
+
+
+def test_decrypt_page_guide_dialog_tells_linux_users_the_truth() -> None:
+ source = read_frontend("pages/decrypt.vue")
+
+ linux_dialog = source.split("await requestGuideDialog(isLinux.value", 1)[1].split(": {", 1)[0]
+ assert "Linux 不执行内存扫描" in linux_dialog
+ # Linux 分支不能承诺「先扫内存、失败再改用 Hook」。
+ assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" not in linux_dialog
+ # 该文案必须仍然保留给 Windows 分支。
+ assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" in source
+
+
+def test_v4_copy_and_attribution_are_hidden_where_memory_scan_does_not_exist() -> None:
+ source = read_frontend("pages/decrypt.vue")
+
+ # 「优先使用 V4 内存扫描」的按钮提示只在 Windows 显示。
+ assert (
+ "'点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。"
+ "您也可以手动输入已知的64位密钥。'"
+ ) in source
+ # V4 扫内存的技术出处说明不适用于 Linux。
+ assert 'v-if="!isMacos && !isLinux"' in source
diff --git a/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl
deleted file mode 100644
index c3b8f4bcac4dc7947712d4738bbd34bfdf73344a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 143501
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