import { execFile } from 'node:child_process'; import { appendFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { join } from 'node:path'; import { prepareBuildArtifactsFromConfigFile } from './context.ts'; const ORIENTATION_RETRY_ATTEMPTS = 4; const ORIENTATION_RETRY_DELAY_MS = 250; const POD_INSTALL_TIMEOUT_MS = 120_000; const execFileAsync = promisify(execFile); export interface CocosBuildHookPaths { projectPath: string; buildPath: string; platform: string; adsConfigPath?: string; extensionName?: string; } export function shouldPrepareBuildArtifactsAtStage(stage: 'onBeforeBuild' | 'onAfterBuild') { return stage === 'onAfterBuild'; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function appendDebugLog(projectPath: string, message: string, extra?: unknown) { try { const logsDir = join(projectPath, 'temp', 'logs'); const logPath = join(logsDir, 'cocos-rewarded-ads-build.log'); const payload = extra == null ? message : `${message} ${JSON.stringify(extra)}`; await mkdir(logsDir, { recursive: true }); await appendFile(logPath, `${new Date().toISOString()} ${payload}\n`, 'utf8'); } catch { // Ignore logging failures to avoid blocking the build pipeline. } } function normalizeProjectPath(value: string | undefined, projectPath: string) { if (!value) { return ''; } if (value.startsWith('project://')) { return join(projectPath, value.slice('project://'.length).replace(/^\/+/, '')); } return value; } export function resolveExpectedAndroidScreenOrientationFromBuildOptions(buildOptions?: Record) { const orientation = buildOptions?.packages?.android?.orientation as { landscapeRight?: boolean; landscapeLeft?: boolean; portrait?: boolean; upsideDown?: boolean; } | undefined; if (!orientation) { return null; } const hasPortrait = Boolean(orientation.portrait || orientation.upsideDown); const hasLandscape = Boolean(orientation.landscapeLeft || orientation.landscapeRight); if (hasPortrait && !hasLandscape) { return 'portrait'; } if (hasLandscape && !hasPortrait) { return 'landscape'; } if (hasPortrait && hasLandscape) { return 'fullSensor'; } return null; } function resolveProjectPath(options: Record) { return options.projectPath ?? options.project ?? options.paths?.project ?? process.cwd(); } export function resolveBuildPath(options: Record, projectPath: string) { const explicitOutputPath = options.dest ?? options.paths?.dest ?? options.outputPath; if (explicitOutputPath) { return normalizeProjectPath(explicitOutputPath, projectPath); } const buildRoot = normalizeProjectPath(options.buildPath, projectPath); const outputName = options.outputName ?? options.taskName ?? options.platform; if (!buildRoot) { return outputName ? join(projectPath, 'build', outputName) : join(projectPath, 'build'); } if (!outputName || buildRoot.endsWith(`/${outputName}`) || buildRoot.endsWith(`\\${outputName}`)) { return buildRoot; } return join(buildRoot, outputName); } export function resolveAdsConfigPath(options: Record, extensionName?: string) { const projectPath = resolveProjectPath(options); const extensionOptions = extensionName ? options.packages?.[extensionName] ?? {} : {}; return extensionOptions.adsConfigPath ?? join(projectPath, 'ads.config.json'); } function resolveExtensionPackageOptions(options: Record, extensionName?: string) { if (!extensionName) { return {}; } return options.packages?.[extensionName] ?? {}; } export function resolvePodExecutableCandidates(runtime: { env?: NodeJS.ProcessEnv; } = {}) { const env = runtime.env ?? process.env; return Array.from(new Set([ env.COCOS_REWARDED_ADS_POD_PATH, '/opt/homebrew/bin/pod', '/usr/local/bin/pod', 'pod', ].filter(Boolean))); } export async function resolveExpectedAndroidScreenOrientation(buildPath: string, buildOptions?: Record) { const orientationFromBuildOptions = resolveExpectedAndroidScreenOrientationFromBuildOptions(buildOptions); if (orientationFromBuildOptions) { return orientationFromBuildOptions; } const compileConfigPath = join(buildPath, 'cocos.compile.config.json'); try { const compileConfig = JSON.parse(await readFile(compileConfigPath, 'utf8')) as { packages?: { android?: { orientation?: { landscapeRight?: boolean; landscapeLeft?: boolean; portrait?: boolean; upsideDown?: boolean; }; }; }; }; const orientation = compileConfig.packages?.android?.orientation; if (!orientation) { return null; } const hasPortrait = Boolean(orientation.portrait || orientation.upsideDown); const hasLandscape = Boolean(orientation.landscapeLeft || orientation.landscapeRight); if (hasPortrait && !hasLandscape) { return 'portrait'; } if (hasLandscape && !hasPortrait) { return 'landscape'; } if (hasPortrait && hasLandscape) { return 'fullSensor'; } } catch { return null; } return null; } export async function resolveCurrentAndroidManifestScreenOrientation(buildPath: string) { const manifestPath = join(buildPath, 'native/engine/android/app/AndroidManifest.xml'); try { const manifest = await readFile(manifestPath, 'utf8'); const activityTagMatch = manifest.match(/]*android:name="com\.cocos\.game\.AppActivity"[^>]*>/m); if (!activityTagMatch) { return null; } const orientationMatch = activityTagMatch[0].match(/android:screenOrientation="([^"]+)"/); return orientationMatch?.[1] ?? null; } catch { return null; } } export async function runIosPodInstall(options: { projectPath: string; buildPath: string; platform: string; stage: 'onBeforeBuild' | 'onAfterBuild'; }, runtime: { execFileAsync?: typeof execFileAsync; appendDebugLog?: typeof appendDebugLog; resolvePodExecutableCandidates?: typeof resolvePodExecutableCandidates; } = {}) { if (options.platform !== 'ios') { return null; } const podfilePath = join(options.buildPath, 'proj', 'Podfile'); try { await readFile(podfilePath, 'utf8'); } catch { throw new Error(`[rewarded-ads] iOS Podfile was not generated at ${podfilePath}`); } const podExecutables = (runtime.resolvePodExecutableCandidates || resolvePodExecutableCandidates)(); const runner = runtime.execFileAsync || execFileAsync; const appendLog = runtime.appendDebugLog || appendDebugLog; const projDir = join(options.buildPath, 'proj'); const podEnv = { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'en_US.UTF-8', }; let lastError: unknown; for (const podExecutable of podExecutables) { try { await appendLog(options.projectPath, `[hook] ${options.stage}:pod-install-spawn`, { podExecutable, cwd: projDir, }); const result = await runner(podExecutable, ['install'], { cwd: projDir, env: podEnv, timeout: POD_INSTALL_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, } as any); await appendLog(options.projectPath, `[hook] ${options.stage}:pod-install-completed`, { podExecutable, stdout: result.stdout == null ? '' : String(result.stdout).trim(), stderr: result.stderr == null ? '' : String(result.stderr).trim(), }); return result; } catch (error) { lastError = error; if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { await appendLog(options.projectPath, `[hook] ${options.stage}:pod-install-missing`, { podExecutable, }); continue; } await appendLog(options.projectPath, `[hook] ${options.stage}:pod-install-failed`, { podExecutable, code: (error as any)?.code, signal: (error as any)?.signal, stdout: (error as any)?.stdout?.trim(), stderr: (error as any)?.stderr?.trim(), message: (error as Error)?.message, }); throw error; } } throw lastError ?? new Error('No usable CocoaPods executable was found for cocos rewarded ads build hook.'); } export async function rewriteIosBoostContainerSimulatorArch(options: { buildPath: string; platform: string; }, runtime: { hostArch?: string; } = {}) { if (options.platform !== 'ios') { return false; } const hostArch = runtime.hostArch ?? process.arch; if (hostArch !== 'arm64') { return false; } const projDir = join(options.buildPath, 'proj'); let entries; try { entries = await readdir(projDir, { withFileTypes: true }); } catch { return false; } const xcodeproj = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.xcodeproj')); if (!xcodeproj) { return false; } const pbxprojPath = join(projDir, xcodeproj.name, 'project.pbxproj'); let pbxproj; try { pbxproj = await readFile(pbxprojPath, 'utf8'); } catch { return false; } const updated = pbxproj .replace(/"ARCHS\[sdk=iphonesimulator\*]" = x86_64;/g, '"ARCHS[sdk=iphonesimulator*]" = arm64;') .replace(/"VALID_ARCHS\[sdk=iphonesimulator\*]" = x86_64;/g, '"VALID_ARCHS[sdk=iphonesimulator*]" = arm64;') .replace(/"EXCLUDED_ARCHS\[sdk=iphonesimulator\*]" = arm64;/g, '"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";'); if (updated === pbxproj) { return false; } await writeFile(pbxprojPath, updated, 'utf8'); return true; } export async function stabilizeAndroidManifestOrientation(options: { buildPath: string; platform: string; buildOptions?: Record; prepare: () => Promise; maxAttempts?: number; retryDelayMs?: number; }) { if (options.platform !== 'android') { return; } const expectedOrientation = await resolveExpectedAndroidScreenOrientation(options.buildPath, options.buildOptions); if (!expectedOrientation) { return; } for (let attempt = 0; attempt < (options.maxAttempts ?? ORIENTATION_RETRY_ATTEMPTS); attempt += 1) { const currentOrientation = await resolveCurrentAndroidManifestScreenOrientation(options.buildPath); if (currentOrientation !== expectedOrientation) { await options.prepare(); } await sleep(options.retryDelayMs ?? ORIENTATION_RETRY_DELAY_MS); const settledOrientation = await resolveCurrentAndroidManifestScreenOrientation(options.buildPath); if (settledOrientation === expectedOrientation) { return; } await options.prepare(); } } export function createCocosBuildExtensionMethods() { return { async prepareBuildArtifacts(options: { projectPath: string; buildPath: string; platform: string; extensionName?: string; adsConfigPath?: string; screenOrientation?: string | null; }) { return await prepareBuildArtifactsFromConfigFile({ adsConfigPath: options.adsConfigPath ?? join(options.projectPath, 'ads.config.json'), cocosBuildOptions: { platform: options.platform, }, outputDir: options.buildPath, projectDir: options.projectPath, extensionName: options.extensionName, screenOrientation: options.screenOrientation, }); }, }; } export function resolveBuildHookPaths(options: Record, extensionName?: string): CocosBuildHookPaths { const projectPath = resolveProjectPath(options); const buildPath = resolveBuildPath(options, projectPath); const extensionOptions = resolveExtensionPackageOptions(options, extensionName) as Record; return { projectPath, buildPath, platform: options.platform, adsConfigPath: extensionOptions.adsConfigPath ?? join(projectPath, 'ads.config.json'), extensionName, }; } export function createCocosBuildExtensionHooks(options?: { extensionName?: string; }) { const extensionName = options?.extensionName; const methods = createCocosBuildExtensionMethods(); async function runStage(stage: 'onBeforeBuild' | 'onAfterBuild', buildOptions: Record) { if (!shouldPrepareBuildArtifactsAtStage(stage)) { return undefined; } const resolved = resolveBuildHookPaths(buildOptions, extensionName); const screenOrientation = resolveExpectedAndroidScreenOrientationFromBuildOptions(buildOptions); const prepare = async () => await methods.prepareBuildArtifacts({ ...resolved, screenOrientation, }); const result = await prepare(); await runIosPodInstall({ projectPath: resolved.projectPath, buildPath: resolved.buildPath, platform: resolved.platform, stage, }); await rewriteIosBoostContainerSimulatorArch({ buildPath: resolved.buildPath, platform: resolved.platform, }); await stabilizeAndroidManifestOrientation({ buildPath: resolved.buildPath, platform: resolved.platform, buildOptions, prepare, }); return result; } return { load() {}, unload() {}, async onBeforeBuild(buildOptions: Record) { return await runStage('onBeforeBuild', buildOptions); }, async onAfterBuild(buildOptions: Record) { return await runStage('onAfterBuild', buildOptions); }, }; } export const methods = createCocosBuildExtensionMethods(); export function load() {} export function unload() {}