Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/sns-media-cross-platform.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: SNS Media Cross-Platform Tests

on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/sns-media-cross-platform.yml"
- "desktop/package.json"
- "desktop/package-lock.json"
- "desktop/scripts/smoke-macos-package.cjs"
- "desktop/scripts/smoke-windows-package.cjs"
- "desktop/scripts/sns-wasm-smoke.cjs"
- "desktop/src/main.cjs"
- "desktop/tests/**"
- "frontend/lib/sns-media-source.js"
- "frontend/pages/sns.vue"
- "frontend/tests/sns-media-source.test.mjs"
- "pyproject.toml"
- "src/wechat_decrypt_tool/backend_entry.py"
- "src/wechat_decrypt_tool/logging_config.py"
- "src/wechat_decrypt_tool/native/weflow_wasm/**"
- "src/wechat_decrypt_tool/request_logging.py"
- "src/wechat_decrypt_tool/routers/sns.py"
- "src/wechat_decrypt_tool/sns_export_service.py"
- "src/wechat_decrypt_tool/sns_media.py"
- "tests/test_sns_media.py"
- "tests/test_sns_media_route_weflow_default.py"
- "tests/test_sns_media_url.py"
- "tests/test_sns_video_thumbnail_proxy.py"
- "tests/test_request_log_redaction.py"
- "tests/test_logging_config_data_dir.py"

permissions:
contents: read

jobs:
sns-media:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Windows x64
os: windows-2022
arch: x64
- name: macOS arm64
os: macos-14
arch: arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: .python-version

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
desktop/package-lock.json
frontend/package-lock.json

- name: Verify runner architecture
env:
EXPECTED_ARCH: ${{ matrix.arch }}
run: node -e "if (process.arch !== process.env.EXPECTED_ARCH) throw new Error('unexpected runner architecture ' + process.arch)"

- name: Install uv
run: python -m pip install uv

- name: Install Python dependencies
run: uv sync --frozen

- name: Install frontend dependencies
working-directory: frontend
run: npm ci

- name: Install desktop dependencies
working-directory: desktop
run: npm ci

- name: Run SNS Python unit and integration tests
env:
PYTHONPATH: src
run: >-
uv run pytest -q
tests/test_sns_media.py
tests/test_sns_media_route_weflow_default.py
tests/test_sns_media_url.py
tests/test_sns_video_thumbnail_proxy.py
tests/test_request_log_redaction.py
tests/test_logging_config_data_dir.py
-k "not html_export and not contacts_export_seal_request_logs_only_redacted_metadata"

- name: Run frontend media source tests
working-directory: frontend
run: node --test tests/sns-media-source.test.mjs

- name: Verify Electron run-as-node fixture and desktop contracts
working-directory: desktop
run: >-
node --test
tests/sns-wasm-runtime.test.cjs
tests/package-config.test.cjs
tests/native-core-runtime.test.cjs
4 changes: 4 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
},
"afterPack": "scripts/after-pack.cjs",
"afterSign": "scripts/after-sign.cjs",
"electronFuses": {
"runAsNode": true,
"resetAdHocDarwinSignature": true
},
"files": [
"src/**/*",
"!src/wcdb-sidecar.cjs",
Expand Down
11 changes: 11 additions & 0 deletions desktop/scripts/build-backend.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,17 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat
}

function validateRuntimeNativeHelpers(destinationDir, platform = process.platform) {
for (const name of [
"weflow_wasm_keystream.js",
"wasm_video_decode.js",
"wasm_video_decode.wasm",
"sns_image_fixture.json",
]) {
const resource = path.join(destinationDir, "weflow_wasm", name);
if (!fs.existsSync(resource) || !fs.statSync(resource).isFile()) {
throw new Error(`Missing SNS WASM runtime resource: ${resource}`);
}
}
if (platform !== "darwin") return;
const imageScanHelper = path.join(destinationDir, "macos", "universal", "image_scan_helper");
if (!fs.existsSync(imageScanHelper)) {
Expand Down
15 changes: 15 additions & 0 deletions desktop/scripts/smoke-macos-package.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const {
applyNativeCoreRuntimePolicy,
} = require("../src/native-core-runtime.cjs");
const { resolveMacosPrivatePkiRuntime } = require("../src/macos-private-pki-runtime.cjs");
const {
smokeElectronNodeWasm,
smokePackagedBackendWasm,
} = require("./sns-wasm-smoke.cjs");

const desktopRoot = path.resolve(__dirname, "..");
const SUPPORTED_ARCHITECTURE = "arm64";
Expand Down Expand Up @@ -403,6 +407,17 @@ async function runPackagedRuntimeSmoke(appPath) {
assert.equal(nativeCoreEnv[ENV_NATIVE_CORE_MODE], "required");
assert.equal(nativeCoreEnv[ENV_NATIVE_CORE_ALLOW_DEVELOPMENT_BUILD], undefined);

const snsKeystreamSha256 = smokeElectronNodeWasm({
electronExecutable,
nativeRoot,
});
const snsBackendSmoke = smokePackagedBackendWasm({
backendExecutable: backend,
electronExecutable,
nativeRoot,
});
assert.equal(snsBackendSmoke.keystreamSha256, snsKeystreamSha256);

assertArchitecture(electronExecutable, "arm64");
assertArchitecture(backend, "arm64");
assertArchitecture(nativeClient, "arm64");
Expand Down
14 changes: 14 additions & 0 deletions desktop/scripts/smoke-windows-package.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const { isBackendHealthResponse } = require("../src/backend-startup.cjs");
const {
ensurePrivatePkiIssuerCached,
} = require("../src/windows-private-pki-runtime.cjs");
const {
smokeElectronNodeWasm,
smokePackagedBackendWasm,
} = require("./sns-wasm-smoke.cjs");

const desktopRoot = path.resolve(__dirname, "..");
const defaultPackageRoot = path.join(desktopRoot, "dist", "win-unpacked");
Expand Down Expand Up @@ -119,6 +123,16 @@ async function smokeRuntime(packageRoot, tempRoot) {
assert.equal(manifest.codeSignatureEnforced, true);
assert.equal(manifest.stagingPinnedSignerTrust, false);
assert.equal(manifest.windowsSignerTrustMode, "private-pki");
const snsKeystreamSha256 = smokeElectronNodeWasm({
electronExecutable: runtime.application,
nativeRoot: path.join(runtime.root, "resources", "backend", "native"),
});
const snsBackendSmoke = smokePackagedBackendWasm({
backendExecutable: runtime.backend,
electronExecutable: runtime.application,
nativeRoot: path.join(runtime.root, "resources", "backend", "native"),
});
assert.equal(snsBackendSmoke.keystreamSha256, snsKeystreamSha256);

const stdoutPath = path.join(tempRoot, "backend.out.log");
const stderrPath = path.join(tempRoot, "backend.err.log");
Expand Down
105 changes: 105 additions & 0 deletions desktop/scripts/sns-wasm-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use strict";

const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");

function resolveSnsWasmFixture(nativeRoot) {
const wasmRoot = path.join(path.resolve(nativeRoot), "weflow_wasm");
const helper = path.join(wasmRoot, "weflow_wasm_keystream.js");
const fixturePath = path.join(wasmRoot, "sns_image_fixture.json");
for (const filePath of [
helper,
fixturePath,
path.join(wasmRoot, "wasm_video_decode.js"),
path.join(wasmRoot, "wasm_video_decode.wasm"),
]) {
assert.ok(fs.statSync(filePath).isFile(), `Missing SNS WASM resource: ${filePath}`);
}
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
assert.match(String(fixture.key || ""), /^\d+$/);
assert.ok(Number(fixture.size) > 0);
return { fixture, fixturePath, helper };
}

function decodeAndVerifyFixture(keystream, fixture) {
assert.equal(keystream.length, Number(fixture.size));
assert.equal(
crypto.createHash("sha256").update(keystream).digest("hex"),
fixture.keystreamSha256,
"SNS WASM keystream hash differs from the fixed fixture"
);
const encrypted = Buffer.from(String(fixture.encryptedBase64 || ""), "base64");
assert.equal(encrypted.length, keystream.length);
const plaintext = Buffer.alloc(encrypted.length);
for (let index = 0; index < encrypted.length; index += 1) {
plaintext[index] = encrypted[index] ^ keystream[index];
}
assert.equal(plaintext.subarray(0, 4).toString("hex"), fixture.plaintextMagicHex);
assert.equal(plaintext[0], 0xff);
assert.equal(plaintext[1], 0xd8);
assert.equal(
crypto.createHash("sha256").update(plaintext).digest("hex"),
fixture.plaintextSha256,
"SNS fixture did not decrypt to the expected JPEG"
);
return plaintext;
}

function smokeElectronNodeWasm({ electronExecutable, nativeRoot, env = process.env }) {
const { fixture, helper } = resolveSnsWasmFixture(nativeRoot);
const result = spawnSync(
path.resolve(electronExecutable),
[helper, String(fixture.key), String(fixture.size)],
{
cwd: path.dirname(helper),
encoding: "utf8",
windowsHide: true,
env: { ...env, ELECTRON_RUN_AS_NODE: "1" },
timeout: 30_000,
}
);
if (result.error) throw result.error;
assert.equal(result.status, 0, result.stderr || result.stdout);
const keystream = Buffer.from(String(result.stdout || "").trim(), "base64");
decodeAndVerifyFixture(keystream, fixture);
return fixture.keystreamSha256;
}

function smokePackagedBackendWasm({ backendExecutable, electronExecutable, nativeRoot, env = process.env }) {
const { fixture } = resolveSnsWasmFixture(nativeRoot);
const smokeEnv = {
...env,
PYTHONPATH: "",
WECHAT_TOOL_NODE_EXECUTABLE: path.resolve(electronExecutable),
WECHAT_TOOL_NODE_MODE: "electron-run-as-node",
};
delete smokeEnv.PYTHONHOME;
delete smokeEnv.ELECTRON_RUN_AS_NODE;
const result = spawnSync(path.resolve(backendExecutable), ["--smoke-sns-wasm"], {
cwd: path.dirname(backendExecutable),
encoding: "utf8",
windowsHide: true,
env: smokeEnv,
timeout: 30_000,
});
if (result.error) throw result.error;
assert.equal(result.status, 0, result.stderr || result.stdout);
const line = String(result.stdout || "").trim().split(/\r?\n/).filter(Boolean).at(-1);
const payload = JSON.parse(line || "{}");
assert.equal(payload.frozen, true);
assert.equal(payload.keystreamProvider, "electron-node-wasm");
assert.equal(payload.mediaType, "image/jpeg");
assert.equal(payload.plaintextSha256, fixture.plaintextSha256);
assert.equal(payload.keystreamSha256, fixture.keystreamSha256);
return payload;
}

module.exports = {
decodeAndVerifyFixture,
resolveSnsWasmFixture,
smokeElectronNodeWasm,
smokePackagedBackendWasm,
};
7 changes: 7 additions & 0 deletions desktop/src/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2130,10 +2130,17 @@ function startBackend() {
WECHAT_TOOL_PORT: String(getBackendPort()),
WECHAT_TOOL_DATA_DIR: resolvedDataPath,
WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath,
// The packaged backend cannot rely on Finder/Explorer inheriting a shell PATH.
// Reuse this exact Electron executable as Node only for the SNS WASM child.
WECHAT_TOOL_NODE_EXECUTABLE: process.execPath,
WECHAT_TOOL_NODE_MODE: "electron-run-as-node",
// Electron decodes the backend pipe as UTF-8. Do not inherit an ambient
// Windows code page such as cp950, which cannot encode Simplified Chinese.
PYTHONIOENCODING: "utf-8",
};
// Never turn the backend (or the Electron main process) globally into Node.
// Python scopes this flag to the single WASM helper subprocess.
delete env.ELECTRON_RUN_AS_NODE;
configureNativeCoreRuntime(env);
clearLegacyWcdbEnvironment(env);
logMain(
Expand Down
3 changes: 3 additions & 0 deletions desktop/tests/native-core-runtime.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@ test("desktop startBackend clears legacy WCDB state and never starts the sidecar
assert.match(startBackend, /clearLegacyWcdbEnvironment\(env\)/);
assert.match(startBackend, /spawn\("uv", \["run", "--no-dev", "main\.py"\]/);
assert.match(startBackend, /PYTHONIOENCODING:\s*"utf-8"/);
assert.match(startBackend, /WECHAT_TOOL_NODE_EXECUTABLE:\s*process\.execPath/);
assert.match(startBackend, /WECHAT_TOOL_NODE_MODE:\s*"electron-run-as-node"/);
assert.match(startBackend, /delete env\.ELECTRON_RUN_AS_NODE/);
assert.doesNotMatch(startBackend, /PYTHONIOENCODING:\s*process\.env\.PYTHONIOENCODING/);
assert.doesNotMatch(startBackend, /startWcdbSidecar\(/);
assert.doesNotMatch(startBackend, /ensureWcdbSidecarEnv\(/);
Expand Down
24 changes: 24 additions & 0 deletions desktop/tests/package-config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ test("desktop package excludes the retired Koffi and WCDB sidecar runtime", () =
assert.ok(packageJson.build.files.includes("!src/wcdb-sidecar.cjs"));
});

test("desktop package keeps Electron run-as-node enabled for the SNS WASM helper", () => {
assert.equal(packageJson.build.electronFuses?.runAsNode, true);
assert.equal(packageJson.build.electronFuses?.resetAdHocDarwinSignature, true);
});

test("SNS media CI covers Windows x64 and macOS arm64 without release secrets", () => {
const workflow = fs.readFileSync(
path.join(repoRoot, ".github", "workflows", "sns-media-cross-platform.yml"),
"utf8",
);
assert.match(workflow, /workflow_dispatch:/);
assert.match(workflow, /pull_request:/);
assert.match(workflow, /os:\s*windows-2022/);
assert.match(workflow, /arch:\s*x64/);
assert.match(workflow, /os:\s*macos-14/);
assert.match(workflow, /arch:\s*arm64/);
assert.match(workflow, /tests\/sns-wasm-runtime\.test\.cjs/);
assert.match(workflow, /tests\/sns-media-source\.test\.mjs/);
assert.match(workflow, /tests\/test_sns_media\.py/);
assert.doesNotMatch(workflow, /secrets\.|environment:\s*windows-private-pki-production/);
});

test("development launcher owns the Electron process tree directly", () => {
const source = fs.readFileSync(path.join(desktopRoot, "scripts", "dev.cjs"), "utf8");
assert.match(source, /const electronCommand = require\("electron"\);/);
Expand Down Expand Up @@ -108,6 +130,8 @@ test("Windows release uses protected cloud private-PKI signing and installer smo
assert.match(smokeSource, /wechatdb_broker\.exe/);
assert.match(smokeSource, /\/api\/health/);
assert.match(smokeSource, /smokeElectronApp/);
assert.match(smokeSource, /smokeElectronNodeWasm/);
assert.match(smokeSource, /smokePackagedBackendWasm/);
assert.match(smokeSource, /AUTO_UPDATE_ENABLED:\s*"0"/);

const workflow = fs
Expand Down
Loading
Loading