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 requirePrivacy = process.argv.includes('--require-privacy'); 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 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 !== true) { fail('app-plus.distribute.splashscreen.useOriginalMsgbox must enable 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.6'); } 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'); } 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 androidIcons = [ ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192] ]; for (const [density, size] of androidIcons) { expectPng(icons?.android?.[density], size, size); } const iosIconPaths = [ [icons?.ios?.appstore, 1024], [icons?.ios?.iphone?.['app@2x'], 120], [icons?.ios?.iphone?.['app@3x'], 180], [icons?.ios?.iphone?.['spotlight@2x'], 80], [icons?.ios?.iphone?.['spotlight@3x'], 120], [icons?.ios?.iphone?.['settings@2x'], 58], [icons?.ios?.iphone?.['settings@3x'], 87], [icons?.ios?.iphone?.['notification@2x'], 40], [icons?.ios?.iphone?.['notification@3x'], 60], [icons?.ios?.ipad?.app, 76], [icons?.ios?.ipad?.['app@2x'], 152], [icons?.ios?.ipad?.['proapp@2x'], 167], [icons?.ios?.ipad?.spotlight, 40], [icons?.ios?.ipad?.['spotlight@2x'], 80], [icons?.ios?.ipad?.settings, 29], [icons?.ios?.ipad?.['settings@2x'], 58], [icons?.ios?.ipad?.notification, 20], [icons?.ios?.ipad?.['notification@2x'], 40] ]; for (const [relativePath, size] of iosIconPaths) expectPng(relativePath, size, size, true); const splashPaths = [ [splashscreen?.android?.hdpi, 480, 762], [splashscreen?.android?.xhdpi, 720, 1242], [splashscreen?.android?.xxhdpi, 1080, 1882], [splashscreen?.ios?.iphone?.retina40, 640, 1136], [splashscreen?.ios?.iphone?.retina47, 750, 1334], [splashscreen?.ios?.iphone?.retina55, 1242, 2208], [splashscreen?.ios?.iphone?.iphonex, 1125, 2436], [splashscreen?.ios?.iphone?.['portrait-896h@2x'], 828, 1792], [splashscreen?.ios?.iphone?.['portrait-896h@3x'], 1242, 2688], [splashscreen?.ios?.ipad?.portrait7, 768, 1024], [splashscreen?.ios?.ipad?.['portrait-retina7'], 1536, 2048], [splashscreen?.ios?.ipad?.['portrait-1112h@2x'], 1668, 2224], [splashscreen?.ios?.ipad?.['portrait-1194h@2x'], 1668, 2388], [splashscreen?.ios?.ipad?.['portrait-1366h@2x'], 2048, 2732] ]; for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height); const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json'); const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json'); if (requirePrivacy) { if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') { fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds'); } if (String(process.env.VITE_DEV_SMS_HINT_VALUE || '').trim()) { fail('VITE_DEV_SMS_HINT_VALUE must not be present for release builds'); } try { releaseLegalUrls = validateLegalUrlPair( process.env.VITE_APP_TERMS_URL, process.env.VITE_APP_PRIVACY_URL ); } catch (error) { fail(`release build legal URL environment is invalid: ${error instanceof Error ? error.message : String(error)}`); } if (!existsSync(privacyPath)) { fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs'); } else { try { const privacy = JSON.parse(readFileSync(privacyPath, 'utf8')); const privacyLegalUrls = validateAndroidPrivacyDocument(privacy); if ( releaseLegalUrls && ( privacyLegalUrls.termsUrl !== releaseLegalUrls.termsUrl || privacyLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl ) ) { fail('androidPrivacy.json legal links must match VITE_APP_TERMS_URL and VITE_APP_PRIVACY_URL'); } releaseLegalUrls = privacyLegalUrls; } catch (error) { fail(`androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); } } if (!existsSync(compiledPrivacyPath)) { fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-privacy'); } else { try { const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8')); const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy); if ( releaseLegalUrls && ( compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl || compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl ) ) { fail('compiled App androidPrivacy.json must match the source privacy configuration'); } } catch (error) { fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); } } 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 (requirePrivacy && 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).`);