import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import {
validateAndroidPrivacyDocument,
validateLegalUrlPair
} from './app-legal-config.mjs';
const projectRoot = resolve(import.meta.dirname, '..');
const manifestPath = resolve(projectRoot, 'src/manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const requireLegalConsent = process.argv.includes('--require-legal-consent');
const checkLegalUrls = process.argv.includes('--check-legal-urls');
const failures = [];
let releaseLegalUrls;
const fail = (message) => failures.push(message);
const compiledAppRoot = resolve(projectRoot, 'dist/build/app');
const compiledManifestPath = resolve(compiledAppRoot, 'manifest.json');
const compiledAppServicePath = resolve(compiledAppRoot, 'app-service.js');
const loginPagePath = resolve(projectRoot, 'src/pages/auth/login/index.vue');
const sourceAndroidManifestPath = resolve(projectRoot, 'AndroidManifest.xml');
const compiledAndroidManifestPath = resolve(compiledAppRoot, 'AndroidManifest.xml');
const sourceNetworkSecurityPath = resolve(
projectRoot,
'nativeResources/android/res/xml/network_security_config.xml'
);
const compiledNetworkSecurityPath = resolve(
compiledAppRoot,
'nativeResources/android/res/xml/network_security_config.xml'
);
const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']);
const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i;
const releaseSensitiveContentPatterns = [
['private key', /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/],
['cloud access key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/],
['GitHub token', /\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b/],
['model API key', /\bsk-[A-Za-z0-9_-]{20,}\b/],
['mobile number', /(? {
if (!existsSync(root)) return [];
const result = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = resolve(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
} else if (releaseTextExtensions.has(entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase())) {
result.push(absolutePath);
}
}
};
visit(root);
return result;
};
const scanCompiledAppForSensitiveValues = () => {
if (!existsSync(compiledAppRoot)) {
fail('compiled App resource directory is missing; run build:app before release verification');
return;
}
const allFiles = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = resolve(directory, entry.name);
if (entry.isDirectory()) visit(absolutePath);
else allFiles.push(absolutePath);
}
};
visit(compiledAppRoot);
for (const file of allFiles) {
const relativePath = relative(compiledAppRoot, file);
if (releaseSensitiveFilePattern.test(relativePath)) {
fail(`compiled App contains a sensitive file: ${relativePath}`);
}
}
for (const file of compiledTextFiles(compiledAppRoot)) {
const content = readFileSync(file, 'utf8');
for (const [label, pattern] of releaseSensitiveContentPatterns) {
if (pattern.test(content)) {
fail(`compiled App contains a possible ${label}: ${relative(compiledAppRoot, file)}`);
}
}
}
};
const readPng = (relativePath) => {
if (typeof relativePath !== 'string' || !relativePath) {
fail('manifest is missing an App asset path');
return undefined;
}
const path = [
resolve(projectRoot, 'src', relativePath),
resolve(projectRoot, relativePath)
].find(existsSync);
if (!path) {
fail(`missing asset: ${relativePath}`);
return undefined;
}
const header = readFileSync(path).subarray(0, 29);
const isPng = header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
if (!isPng || header.toString('ascii', 12, 16) !== 'IHDR') {
fail(`invalid PNG: ${relativePath}`);
return undefined;
}
return {
width: header.readUInt32BE(16),
height: header.readUInt32BE(20),
colorType: header[25]
};
};
const expectPng = (relativePath, width, height, opaque = false) => {
const png = readPng(relativePath);
if (!png) return;
if (png.width !== width || png.height !== height) {
fail(`unexpected dimensions for ${relativePath}: got ${png.width}x${png.height}, expected ${width}x${height}`);
}
if (opaque && (png.colorType === 4 || png.colorType === 6)) {
fail(`iOS App icon must not contain alpha: ${relativePath}`);
}
};
const app = manifest['app-plus'];
const distribute = app?.distribute;
const icons = distribute?.icons;
const splashscreen = distribute?.splashscreen;
const android = distribute?.android;
const modules = app?.modules;
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
fail('app-plus.screenOrientation must include portrait-primary');
}
if (splashscreen?.useOriginalMsgbox !== false) {
fail('app-plus.distribute.splashscreen.useOriginalMsgbox must disable the native privacy prompt');
}
if (app?.ssl?.untrustedca !== 'refuse') {
fail('app-plus.ssl.untrustedca must remain refuse');
}
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) {
fail('versionName must use semantic numeric form such as 0.1.8');
}
if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) {
fail('versionCode must be a positive integer');
}
if (android?.packagename !== 'com.yincheng.wygj') {
fail('Android package name must remain com.yincheng.wygj');
}
if (android?.targetSdkVersion !== 35) {
fail('Android targetSdkVersion must be 35 for the August 1 test package');
}
if (android?.usesCleartextTraffic !== false) {
fail('Android manifest configuration must explicitly disable cleartext traffic');
}
const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a'];
const actualAndroidAbis = Array.isArray(android?.abiFilters)
? [...android.abiFilters].sort()
: [];
if (JSON.stringify(actualAndroidAbis) !== JSON.stringify(expectedAndroidAbis)) {
fail('Android abiFilters must include exactly armeabi-v7a and arm64-v8a');
}
if (android?.permissionExternalStorage?.request !== 'none') {
fail('Android external storage permission must not be requested at startup');
}
if (android?.permissionPhoneState?.request !== 'none') {
fail('Android phone state permission must not be requested at startup');
}
if (!Object.hasOwn(modules || {}, 'Camera') || !Object.hasOwn(modules || {}, 'Record')) {
fail('App modules must include Camera and Record for user-triggered media features');
}
const declaredAndroidPermissions = Array.isArray(android?.permissions)
? android.permissions.join('\n')
: '';
for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission.MODIFY_AUDIO_SETTINGS']) {
if (!declaredAndroidPermissions.includes(permission)) {
fail(`Android permissions must include ${permission}`);
}
}
const verifyAndroidNetworkSecurity = (manifestPath, networkSecurityPath, label) => {
if (!existsSync(manifestPath)) {
fail(`${label} is missing AndroidManifest.xml`);
return;
}
if (!existsSync(networkSecurityPath)) {
fail(`${label} is missing network_security_config.xml`);
return;
}
const androidManifest = readFileSync(manifestPath, 'utf8');
const networkSecurity = readFileSync(networkSecurityPath, 'utf8');
if (!/android:usesCleartextTraffic\s*=\s*["']false["']/.test(androidManifest)) {
fail(`${label} AndroidManifest.xml must disable cleartext traffic`);
}
if (!/android:networkSecurityConfig\s*=\s*["']@xml\/network_security_config["']/.test(androidManifest)) {
fail(`${label} AndroidManifest.xml must reference network_security_config`);
}
if (!/cleartextTrafficPermitted\s*=\s*["']false["']/.test(networkSecurity)) {
fail(`${label} network security config must reject cleartext traffic`);
}
if (/cleartextTrafficPermitted\s*=\s*["']true["']/.test(networkSecurity)) {
fail(`${label} network security config must not contain a cleartext exception`);
}
if (!//.test(networkSecurity)) {
fail(`${label} network security config must trust system certificates only`);
}
if (//],
['terms link', /openLegalDocument\('terms'\)/],
['privacy link', /openLegalDocument\('privacy'\)/],
['SMS legal gate', /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/],
['login legal gate', /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/]
]) {
if (!pattern.test(loginSource)) {
fail(`login page is missing ${label}`);
}
}
}
if (!existsSync(compiledAppServicePath)) {
fail('compiled App resource is missing app-service.js; run build:app');
} else if (releaseLegalUrls) {
const appService = readFileSync(compiledAppServicePath, 'utf8');
if (!appService.includes(releaseLegalUrls.termsUrl)) {
fail('compiled App login flow is missing VITE_APP_TERMS_URL');
}
if (!appService.includes(releaseLegalUrls.privacyUrl)) {
fail('compiled App login flow is missing VITE_APP_PRIVACY_URL');
}
if (!appService.includes('请先阅读并勾选同意')) {
fail('compiled App login flow is missing the unchecked-consent failure message');
}
}
verifyAndroidNetworkSecurity(
compiledAndroidManifestPath,
compiledNetworkSecurityPath,
'compiled App'
);
scanCompiledAppForSensitiveValues();
}
const checkRemoteLegalPage = async (url, label, expectedText) => {
try {
const response = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: AbortSignal.timeout(10000),
headers: { 'user-agent': 'Bangdao-App-Release-Preflight/1.0' }
});
if (!response.ok) {
fail(`${label} returned HTTP ${response.status}`);
return;
}
if (!String(response.url).startsWith('https://')) {
fail(`${label} redirected away from HTTPS`);
return;
}
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
fail(`${label} must return an HTML document`);
return;
}
const body = await response.text();
if (body.trim().length < 100 || !expectedText.test(body)) {
fail(`${label} does not contain recognizable final legal content`);
}
} catch (error) {
fail(`${label} is not publicly reachable: ${error instanceof Error ? error.message : String(error)}`);
}
};
if (requireLegalConsent && checkLegalUrls && releaseLegalUrls) {
await Promise.all([
checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/),
checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/)
]);
}
if (failures.length) {
console.error(`App asset verification failed:\n- ${failures.join('\n- ')}`);
process.exit(1);
}
console.log(`App asset verification passed (${androidIcons.length + iosIconPaths.length} icons, ${splashPaths.length} splash images).`);