import { access, copyFile, mkdir, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; import { dirname, join, relative, resolve as resolvePath } from 'node:path'; import type { RewardedAdTargetPlatform } from '../../core/src/types.ts'; import type { ResolvedNativeProviderRuntimeConfig } from './native-provider.ts'; export interface NativePatchResult { patchedFiles: string[]; skippedFiles: string[]; warnings: string[]; iosPatchRoots: string[]; iosResolvedTargetName?: string | null; } const KOTLIN_GRADLE_PLUGIN_VERSION = '2.2.0'; const ANDROID_JVM_TARGET_VERSION = '17'; async function fileExists(path: string) { try { await access(path, fsConstants.F_OK); return true; } catch { return false; } } function escapeXml(value: string) { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function normalizePathForCMake(value: string) { return value.replace(/\\/g, '/'); } function resolveGradleProperty(content: string, key: string) { const pattern = new RegExp(`^${key}=(.*)$`, 'm'); const match = content.match(pattern); if (!match) { return ''; } return match[1].trim(); } function replaceGradleProperty(content: string, key: string, value: string) { const nextLine = `${key}=${value}`; const pattern = new RegExp(`^${key}=.*$`, 'm'); if (pattern.test(content)) { return content.replace(pattern, nextLine); } return `${content.trimEnd()}\n${nextLine}\n`; } function ensureContains(content: string, snippet: string, insertAfterPattern?: RegExp) { if (content.includes(snippet)) { return content; } if (!insertAfterPattern) { return `${content.trimEnd()}\n${snippet}\n`; } const match = content.match(insertAfterPattern); if (!match || match.index == null) { return `${content.trimEnd()}\n${snippet}\n`; } const insertIndex = match.index + match[0].length; return `${content.slice(0, insertIndex)}\n${snippet}${content.slice(insertIndex)}`; } function findNamedBlock(content: string, blockName: string) { const startMatch = content.match(new RegExp(`${blockName}\\s*\\{`)); if (!startMatch || startMatch.index == null) { return null; } const start = startMatch.index; const openBraceIndex = start + startMatch[0].lastIndexOf('{'); let depth = 0; for (let index = openBraceIndex; index < content.length; index += 1) { const char = content[index]; if (char === '{') { depth += 1; continue; } if (char === '}') { depth -= 1; if (depth === 0) { return { start, end: index + 1, content: content.slice(start, index + 1), }; } } } return null; } function patchAndroidRootBuildGradle(content: string) { const kotlinPluginClasspath = ` classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:${KOTLIN_GRADLE_PLUGIN_VERSION}'`; if (/classpath ['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin:[^'"]+['"]/.test(content)) { return content.replace( /classpath ['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin:[^'"]+['"]/, kotlinPluginClasspath, ); } return ensureContains( content, kotlinPluginClasspath, /classpath ['"]com\.android\.tools\.build:gradle:[^'"]+['"]/, ); } function patchAndroidModuleBuildscript(content: string) { const buildscriptBlock = `buildscript { repositories { google() mavenCentral() } dependencies { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:${KOTLIN_GRADLE_PLUGIN_VERSION}' } } `; const buildscript = findNamedBlock(content, 'buildscript'); if (!buildscript) { return `${buildscriptBlock}\n${content.trimStart()}`; } let nextBlock = buildscript.content; if (/classpath ['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin:[^'"]+['"]/.test(nextBlock)) { nextBlock = nextBlock.replace( /classpath ['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin:[^'"]+['"]/, `classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:${KOTLIN_GRADLE_PLUGIN_VERSION}'`, ); } else if (findNamedBlock(nextBlock, 'dependencies')) { nextBlock = nextBlock.replace( /dependencies\s*\{[\s\S]*?\n\s*\}/m, (match) => ensureContains( match, ` classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:${KOTLIN_GRADLE_PLUGIN_VERSION}'`, ), ); } else { nextBlock = ensureContains( nextBlock, ` dependencies {\n classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:${KOTLIN_GRADLE_PLUGIN_VERSION}'\n }`, /repositories\s*\{[\s\S]*?\n\s*\}/m, ); } if (!/repositories\s*\{[\s\S]*?google\(\)[\s\S]*?mavenCentral\(\)[\s\S]*?\}/m.test(nextBlock)) { nextBlock = nextBlock.replace( /buildscript\s*\{/, `buildscript {\n repositories {\n google()\n mavenCentral()\n }`, ); } return `${content.slice(0, buildscript.start)}${nextBlock}${content.slice(buildscript.end)}`; } function patchAndroidBuildGradle(content: string) { let next = patchAndroidModuleBuildscript(content); if (!/apply plugin: ['"](kotlin-android|org\.jetbrains\.kotlin\.android)['"]/.test(next)) { next = ensureContains( next, `apply plugin: 'kotlin-android'`, /apply plugin: 'com\.android\.application'/, ); } const compileOptionsBlock = ` compileOptions { sourceCompatibility JavaVersion.VERSION_${ANDROID_JVM_TARGET_VERSION} targetCompatibility JavaVersion.VERSION_${ANDROID_JVM_TARGET_VERSION} }`; if (/compileOptions\s*\{[\s\S]*?\}/m.test(next)) { next = next.replace(/compileOptions\s*\{[\s\S]*?\}/m, compileOptionsBlock); } else { next = ensureContains( next, compileOptionsBlock, /android\s*\{[\s\S]*?\n/m, ); } const kotlinOptionsBlock = ` kotlinOptions { jvmTarget = '${ANDROID_JVM_TARGET_VERSION}' }`; if (/kotlinOptions\s*\{[\s\S]*?jvmTarget\s*=\s*['"]17['"][\s\S]*?\}/m.test(next)) { return ensureContains( next, 'apply from: "../rewarded-ads-dependencies.gradle"', /apply plugin: ['"](kotlin-android|org\.jetbrains\.kotlin\.android|com\.android\.application)['"]/, ); } if (/kotlinOptions\s*\{[\s\S]*?\}/m.test(next)) { next = next.replace(/kotlinOptions\s*\{[\s\S]*?\}/m, kotlinOptionsBlock); } else { next = ensureContains( next, kotlinOptionsBlock, /compileOptions\s*\{[\s\S]*?\n\s*\}/m, ); } return ensureContains( next, 'apply from: "../rewarded-ads-dependencies.gradle"', /apply plugin: ['"](kotlin-android|org\.jetbrains\.kotlin\.android|com\.android\.application)['"]/, ); } function patchAndroidManifest(content: string, appId: string) { const metaDataTag = ` `; const existingMetaDataPattern = /]*android:name="com\.google\.android\.gms\.ads\.APPLICATION_ID"[\s\S]*?\/>/m; if (existingMetaDataPattern.test(content)) { return content.replace(existingMetaDataPattern, metaDataTag); } return ensureContains(content, metaDataTag, /]*>/); } function patchAndroidAppScreenOrientation(content: string, screenOrientation: string) { const activityOpenTagPattern = /]*android:name="com\.cocos\.game\.AppActivity"[^>]*>/m; const activityTagMatch = content.match(activityOpenTagPattern); if (!activityTagMatch || activityTagMatch.index == null) { return content; } const currentActivityTag = activityTagMatch[0]; const nextActivityTag = /android:screenOrientation="[^"]*"/.test(currentActivityTag) ? currentActivityTag.replace(/android:screenOrientation="[^"]*"/, `android:screenOrientation="${screenOrientation}"`) : currentActivityTag.replace(/>$/, ` android:screenOrientation="${screenOrientation}">`); return `${content.slice(0, activityTagMatch.index)}${nextActivityTag}${content.slice(activityTagMatch.index + currentActivityTag.length)}`; } function patchAndroidStringsXml(content: string, appName: string) { const appNameTag = ` ${escapeXml(appName)}`; const existingAppNamePattern = /]*name="app_name"[^>]*>[\s\S]*?<\/string>/m; if (existingAppNamePattern.test(content)) { return content.replace(existingAppNamePattern, appNameTag); } if (//m.test(content)) { return ensureContains(content, appNameTag, //m); } return `\n\n${appNameTag}\n\n`; } function patchCfgCmake(content: string, cocosEnginePath: string) { const normalizedEnginePath = normalizePathForCMake(cocosEnginePath); const nextLine = `set(COCOS_X_PATH "${normalizedEnginePath}")`; const existingPattern = /^set\(COCOS_X_PATH\s+"[^"]*"\)\s*$/m; if (existingPattern.test(content)) { return content.replace(existingPattern, nextLine); } return `${nextLine}\n${content.trimStart()}`; } function patchAndroidGradleProperties(content: string, options: { minimumSdkVersion?: number | null; appName?: string | null; cocosEnginePath?: string | null; outputDir?: string; nativeDir?: string | null; }) { const minSdkPattern = /^PROP_MIN_SDK_VERSION=(\d+)\s*$/m; const match = content.match(minSdkPattern); let next = content; if (options.minimumSdkVersion != null) { if (!match) { next = `${next.trimEnd()}\nPROP_MIN_SDK_VERSION=${options.minimumSdkVersion}\n`; } else { const currentSdkVersion = Number.parseInt(match[1], 10); if (Number.isFinite(currentSdkVersion) && currentSdkVersion < options.minimumSdkVersion) { next = next.replace(minSdkPattern, `PROP_MIN_SDK_VERSION=${options.minimumSdkVersion}`); } } } if (options.appName) { next = replaceGradleProperty(next, 'PROP_APP_NAME', options.appName); } if (options.cocosEnginePath) { next = replaceGradleProperty(next, 'COCOS_ENGINE_PATH', normalizePathForCMake(options.cocosEnginePath)); } if (options.outputDir) { const absoluteOutputDir = resolvePath(options.outputDir); next = replaceGradleProperty(next, 'RES_PATH', normalizePathForCMake(absoluteOutputDir)); } if (options.nativeDir) { next = replaceGradleProperty(next, 'NATIVE_DIR', normalizePathForCMake(resolvePath(options.nativeDir))); } return next; } function patchAndroidAppActivityJava(content: string) { let next = ensureContains(content, 'import com.tinivo.rewardedads.RewardedAdsInstaller;', /import com\.cocos\.lib\.CocosActivity;/); next = next.replace(/^[ \t]*RewardedAdsInstaller(?:\.INSTANCE)?\.install\(this\);[ \t]*(?:\r?\n)?/gm, ''); next = next.replace(/^[ \t]*RewardedAdsInstaller(?:\.INSTANCE)?\.reset\(\);[ \t]*(?:\r?\n)?/gm, ''); next = ensureContains(next, ' RewardedAdsInstaller.install(this);', /super\.onCreate\(savedInstanceState\);\n?/); next = ensureContains(next, ' RewardedAdsInstaller.reset();', /super\.onDestroy\(\);\n?/); return next; } function patchAndroidAppActivityKotlin(content: string) { let next = ensureContains(content, 'import com.tinivo.rewardedads.RewardedAdsInstaller', /import .*CocosActivity.*\n?/); next = next.replace(/^[ \t]*RewardedAdsInstaller(?:\.INSTANCE)?\.install\(this\)[ \t]*(?:\r?\n)?/gm, ''); next = next.replace(/^[ \t]*RewardedAdsInstaller(?:\.INSTANCE)?\.reset\(\)[ \t]*(?:\r?\n)?/gm, ''); next = ensureContains(next, ' RewardedAdsInstaller.install(this)', /super\.onCreate\(savedInstanceState\)\n?/); next = ensureContains(next, ' RewardedAdsInstaller.reset()', /super\.onDestroy\(\)\n?/); return next; } function patchIosPodfile(content: string, fragment: string) { if (content.includes(fragment.trim())) { return content; } const marker = /\nend\s*$/m; const match = content.match(marker); if (!match || match.index == null) { return `${content.trimEnd()}\n${fragment.trim()}\n`; } return `${content.slice(0, match.index)} ${fragment.trim()}\n${content.slice(match.index)}`; } function patchIosCMakeLists(content: string, bridgeBaseName: string) { const bridgeLines = ` \${CC_PROJECT_DIR}/${bridgeBaseName}.h \${CC_PROJECT_DIR}/${bridgeBaseName}.mm `; const projectSourcesBlock = `list(APPEND CC_PROJ_SOURCES \${CC_PROJECT_DIR}/RewardedAdsInstaller.h \${CC_PROJECT_DIR}/RewardedAdsInstaller.mm ${bridgeLines})`; const assetFilesBlock = `list(APPEND CC_ASSET_FILES \${CC_PROJECT_DIR}/rewarded-provider-config.json )`; let next = content; if (!next.includes('${CC_PROJECT_DIR}/RewardedAdsInstaller.mm')) { next = ensureContains(next, projectSourcesBlock, /list\(APPEND CC_PROJ_SOURCES[\s\S]*?\)\n?/); } if (!next.includes('${CC_PROJECT_DIR}/rewarded-provider-config.json')) { next = next.replace( /set\(CC_ASSET_FILES\)\n?/, `set(CC_ASSET_FILES)\n${assetFilesBlock}\n`, ); } return next; } function patchIosAppDelegate(content: string) { let next = content; if (!next.includes('#import ')) { next = next.replace( /#import "AppDelegate\.h"\n?/, '#import "AppDelegate.h"\n#import \n', ); } if (!next.includes('TinivoInstallRewardedAds(self.window.rootViewController);')) { next = next.replace( /\n([ \t]*)return YES;\n/, '\n$1TinivoInstallRewardedAds(self.window.rootViewController);\n$1return YES;\n', ); } if (!next.includes('TinivoResetRewardedAds();')) { if (/- \(void\)applicationWillTerminate:\(UIApplication \*\)application \{\n/.test(next)) { next = next.replace( /(- \(void\)applicationWillTerminate:\(UIApplication \*\)application \{\n)/, '$1 TinivoResetRewardedAds();\n', ); } else { next = `${next.trimEnd()}\n\n- (void)applicationWillTerminate:(UIApplication *)application {\n TinivoResetRewardedAds();\n}\n`; } } return next; } function removeIosCMakeIntegration(content: string) { return content .replace(/\n?list\(APPEND CC_ASSET_FILES\s*\n\s*\$\{CC_PROJECT_DIR\}\/rewarded-provider-config\.json\s*\n\)\n?/m, '\n') .replace(/\n?list\(APPEND CC_PROJ_SOURCES\s*\n\s*\$\{CC_PROJECT_DIR\}\/RewardedAdsInstaller\.h\s*\n\s*\$\{CC_PROJECT_DIR\}\/RewardedAdsInstaller\.mm\s*\n(?:\s*\$\{CC_PROJECT_DIR\}\/RewardedAds(?:AdMob|Max|TopOn)Bridge\.h\s*\n\s*\$\{CC_PROJECT_DIR\}\/RewardedAds(?:AdMob|Max|TopOn)Bridge\.mm\s*\n)?\)\n?/m, '\n') .replace(/\n?set_source_files_properties\(\s*\n(?:\s*\$\{CC_PROJECT_DIR\}\/RewardedAds(?:Installer|AdMobBridge|MaxBridge|TopOnBridge)\.(?:h|mm)\s*\n)+\s*PROPERTIES COMPILE_FLAGS "-fobjc-arc"\s*\)\n?/m, '\n') .replace(/\n?set\(TINIVO_ADMOB_[^)]+\)\n?/g, '\n') .replace(/\n?target_include_directories\(\$\{EXECUTABLE_NAME\} PRIVATE[\s\S]*?GoogleMobileAds\.framework\/Headers[\s\S]*?\)\n?/m, '\n') .replace(/\n?target_link_directories\(\$\{EXECUTABLE_NAME\} PRIVATE[\s\S]*?TINIVO_ADMOB_[\s\S]*?\)\n?/m, '\n') .replace(/\n?target_link_libraries\(\$\{EXECUTABLE_NAME\}\s*\n\s*"-framework GoogleMobileAds"\s*\n\)\n?/m, '\n') .replace(/\n{3,}/g, '\n\n'); } function removeIosAppDelegateIntegration(content: string) { return content .replace(/^#import "RewardedAdsInstaller\.h"\n/m, '') .replace(/^#import \n/m, '') .replace(/^#import \n/m, '') .replace(/^[ \t]*TinivoInstallRewardedAds\(self\.window\.rootViewController\);\n/m, '') .replace(/^[ \t]*TinivoResetRewardedAds\(\);\n/m, ''); } function patchIosInfoPlist(content: string, appId: string) { const keyPattern = /GADApplicationIdentifier<\/key>\s*.*?<\/string>/s; const entry = `GADApplicationIdentifier\n\t${escapeXml(appId)}`; if (keyPattern.test(content)) { return content.replace(keyPattern, entry); } return content.replace(/<\/dict>\s*<\/plist>\s*$/s, `\t${entry}\n\n\n`); } function renderIosPodfile(targetName: string, localPodRelativePath: string) { return `platform :ios, '13.0' target '${targetName}' do pod 'PRAdsKit', :path => '${localPodRelativePath}' end `; } function resolvePreferredIosTargetName(pbxproj: string, xcodeprojName: string) { const targetNames = Array.from( pbxproj.matchAll(/PBXNativeTarget\s+"([^"]+)"/g), (match) => match[1], ); if (targetNames.length === 0) { return null; } const preferredProjectTarget = xcodeprojName.replace(/\.xcodeproj$/, ''); if (targetNames.includes(preferredProjectTarget)) { return preferredProjectTarget; } const ignoredTargets = new Set([ 'ALL_BUILD', 'ZERO_CHECK', 'boost_container', 'builtin-res', 'cocos_engine', 'genbindings', ]); const appTarget = targetNames.find((name) => !ignoredTargets.has(name) && !name.startsWith('Pods-')); return appTarget ?? targetNames[0]; } async function resolveExportedIosProjectInfo(outputDir: string) { const projDir = join(outputDir, 'proj'); if (!await fileExists(projDir)) { return null; } const entries = await readdir(projDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || !entry.name.endsWith('.xcodeproj')) { continue; } const pbxprojPath = join(projDir, entry.name, 'project.pbxproj'); if (!await fileExists(pbxprojPath)) { continue; } const pbxproj = await readFile(pbxprojPath, 'utf8'); const targetName = resolvePreferredIosTargetName(pbxproj, entry.name); if (targetName) { return { xcodeprojDir: join(projDir, entry.name), pbxprojPath, targetName, }; } } return null; } async function hasIosPatchTargets(iosRootDir: string) { return await fileExists(join(iosRootDir, 'AppDelegate.mm')) || await fileExists(join(iosRootDir, 'CMakeLists.txt')) || await fileExists(join(iosRootDir, 'Podfile.in')) || await fileExists(join(iosRootDir, 'Podfile')); } async function writeIfChanged(path: string, content: string, result: NativePatchResult) { const current = await readFile(path, 'utf8'); if (current === content) { result.skippedFiles.push(path); return; } await writeFile(path, content, 'utf8'); result.patchedFiles.push(path); } async function deleteIfExists(path: string, result: NativePatchResult) { if (!await fileExists(path)) { result.skippedFiles.push(path); return; } await unlink(path); result.patchedFiles.push(path); } async function writeOrCreateIfChanged(path: string, content: string, result: NativePatchResult) { if (await fileExists(path)) { await writeIfChanged(path, content, result); return; } await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, 'utf8'); result.patchedFiles.push(path); } async function copyFileIfMissing(sourcePath: string, outputPath: string, result: NativePatchResult) { if (await fileExists(outputPath)) { result.skippedFiles.push(outputPath); return; } await mkdir(dirname(outputPath), { recursive: true }); await copyFile(sourcePath, outputPath); result.patchedFiles.push(outputPath); } async function copyDirectoryIfMissing(sourceDir: string, outputDir: string, result: NativePatchResult) { const entries = await readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = join(sourceDir, entry.name); const outputPath = join(outputDir, entry.name); if (entry.isDirectory()) { await copyDirectoryIfMissing(sourcePath, outputPath, result); continue; } await copyFileIfMissing(sourcePath, outputPath, result); } } async function findAndroidAppActivity(androidRootDir: string) { const javaPath = join(androidRootDir, 'app/src/com/cocos/game/AppActivity.java'); if (await fileExists(javaPath)) { return javaPath; } const ktPath = join(androidRootDir, 'app/src/com/cocos/game/AppActivity.kt'); if (await fileExists(ktPath)) { return ktPath; } // Neither the .java nor the .kt entry point was found at the standard Cocos package path. // This usually means the Android activity package name has been changed from the Cocos // default (com.cocos.game). The RewardedAdsInstaller injection will be skipped for this // directory. If ads are not initialising at runtime, add the following calls manually: // onCreate: RewardedAdsInstaller.install(this) // onDestroy: RewardedAdsInstaller.reset() console.warn( `[rewarded-ads] AppActivity not found at ${join(androidRootDir, 'app/src/com/cocos/game/')}. ` + 'If you customised the Android package name, the RewardedAdsInstaller auto-injection was skipped. ' + 'Add RewardedAdsInstaller.install(this) / .reset() calls to your AppActivity manually.', ); return null; } async function resolveProjectPackageJson(projectDir?: string) { if (!projectDir) { return null; } const packageJsonPath = join(resolvePath(projectDir), 'package.json'); if (!await fileExists(packageJsonPath)) { return null; } try { const content = await readFile(packageJsonPath, 'utf8'); return JSON.parse(content) as Record; } catch { return null; } } async function normalizeCocosEnginePath(candidate: string | undefined | null) { if (!candidate) { return null; } const trimmed = candidate.trim(); if (!trimmed) { return null; } const resolvedCandidate = resolvePath(trimmed); if (await fileExists(join(resolvedCandidate, 'CMakeLists.txt'))) { return resolvedCandidate; } const nativePath = join(resolvedCandidate, 'native'); if (await fileExists(join(nativePath, 'CMakeLists.txt'))) { return nativePath; } return null; } async function resolveCocosEnginePath(options: { outputDir: string; projectDir?: string; }) { const gradlePropertiesPath = join(options.outputDir, 'proj/gradle.properties'); if (await fileExists(gradlePropertiesPath)) { const gradleProperties = await readFile(gradlePropertiesPath, 'utf8'); const gradleEnginePath = await normalizeCocosEnginePath(resolveGradleProperty(gradleProperties, 'COCOS_ENGINE_PATH')); if (gradleEnginePath) { return gradleEnginePath; } } const envEnginePath = await normalizeCocosEnginePath(process.env.COCOS_ENGINE_PATH); if (envEnginePath) { return envEnginePath; } const packageJson = await resolveProjectPackageJson(options.projectDir); const creatorVersion = typeof packageJson?.creator?.version === 'string' ? packageJson.creator.version : ''; const versionedDefaultPath = await normalizeCocosEnginePath( creatorVersion ? `/Applications/Cocos/Creator/${creatorVersion}/CocosCreator.app/Contents/Resources/resources/3d/engine/native` : '', ); if (versionedDefaultPath) { return versionedDefaultPath; } try { const creatorVersions = await readdir('/Applications/Cocos/Creator'); const sortedVersions = creatorVersions.sort((left, right) => right.localeCompare(left, undefined, { numeric: true })); for (const version of sortedVersions) { const candidate = await normalizeCocosEnginePath(`/Applications/Cocos/Creator/${version}/CocosCreator.app/Contents/Resources/resources/3d/engine/native`); if (candidate) { return candidate; } } } catch { // Ignore environment-specific lookup errors and fall through. } // Windows: Cocos Dashboard installs Creator under %LOCALAPPDATA%\cocos\Creator\\ // Falls back to scanning all installed versions when a specific version is not known. const windowsLocalAppData = process.env.LOCALAPPDATA; if (windowsLocalAppData) { const windowsCreatorBase = join(windowsLocalAppData, 'cocos', 'Creator'); const windowsEngineSuffix = 'CocosCreator.exe.Resources/resources/3d/engine/native'; if (creatorVersion) { const windowsVersionedPath = await normalizeCocosEnginePath( join(windowsCreatorBase, creatorVersion, windowsEngineSuffix), ); if (windowsVersionedPath) { return windowsVersionedPath; } } try { const windowsCreatorVersions = await readdir(windowsCreatorBase); const sortedWindowsVersions = windowsCreatorVersions.sort( (left, right) => right.localeCompare(left, undefined, { numeric: true }), ); for (const version of sortedWindowsVersions) { const candidate = await normalizeCocosEnginePath( join(windowsCreatorBase, version, windowsEngineSuffix), ); if (candidate) { return candidate; } } } catch { // Ignore environment-specific lookup errors and fall through. } } return null; } async function resolveAndroidTemplateDir(cocosEnginePath?: string | null) { if (!cocosEnginePath) { return null; } const engineRootDir = dirname(cocosEnginePath); const templateDir = join(engineRootDir, 'templates/android/template'); if (await fileExists(join(templateDir, 'build.gradle'))) { return templateDir; } return null; } async function resolveAndroidScreenOrientation(outputDir: string) { const compileConfigPath = join(outputDir, 'cocos.compile.config.json'); if (!await fileExists(compileConfigPath)) { return null; } 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; } async function resolveCommonTemplateDir(cocosEnginePath?: string | null) { if (!cocosEnginePath) { return null; } const engineRootDir = dirname(cocosEnginePath); const templateDir = join(engineRootDir, 'templates/common'); if (await fileExists(join(templateDir, 'CMakeLists.txt'))) { return templateDir; } return null; } async function resolveAndroidAppName(options: { outputDir: string; projectDir?: string; }) { const gradlePropertiesPath = join(options.outputDir, 'proj/gradle.properties'); if (await fileExists(gradlePropertiesPath)) { const gradleProperties = await readFile(gradlePropertiesPath, 'utf8'); const appName = resolveGradleProperty(gradleProperties, 'PROP_APP_NAME'); if (appName) { return appName; } } const packageJson = await resolveProjectPackageJson(options.projectDir); if (typeof packageJson?.name === 'string' && packageJson.name.trim().length > 0) { return packageJson.name.trim(); } return 'Cocos Game'; } function resolveMinimumAndroidSdkVersion(runtimeConfig?: ResolvedNativeProviderRuntimeConfig | null) { if (!runtimeConfig) { return null; } if (runtimeConfig.provider === 'admob') { return 23; } return null; } async function removeLegacyAndroidJavaSources(androidRootDir: string, result: NativePatchResult) { const rewardedAdsSourceDir = join(androidRootDir, 'app/src/com/tinivo/rewardedads'); const ownedSources = [ 'RewardedAdsInstaller', 'RewardedAdsAdMobBridge', 'RewardedAdsMaxBridge', 'RewardedAdsTopOnBridge', ]; for (const sourceName of ownedSources) { const kotlinPath = join(rewardedAdsSourceDir, `${sourceName}.kt`); const javaPath = join(rewardedAdsSourceDir, `${sourceName}.java`); if (!await fileExists(kotlinPath)) { continue; } await deleteIfExists(javaPath, result); } } async function resolveAndroidGradleNativeDir(options: { outputDir: string; projectDir?: string; }) { const outputNativeDir = join(options.outputDir, 'native/engine/android'); if (await fileExists(join(outputNativeDir, 'build.gradle'))) { return outputNativeDir; } if (options.projectDir) { const projectNativeDir = join(options.projectDir, 'native/engine/android'); if (await fileExists(join(projectNativeDir, 'build.gradle'))) { return projectNativeDir; } } return outputNativeDir; } async function ensureAndroidTemplateHostFiles(options: { outputDir: string; cocosEnginePath?: string | null; }, result: NativePatchResult) { const templateDir = await resolveAndroidTemplateDir(options.cocosEnginePath); const outputAndroidRootDir = join(options.outputDir, 'native/engine/android'); if (templateDir) { const requiredFiles = [ ['build.gradle', 'build.gradle'], ['CMakeLists.txt', 'CMakeLists.txt'], ['app/build.gradle', 'app/build.gradle'], ['app/proguard-rules.pro', 'app/proguard-rules.pro'], ['app/AndroidManifest.xml', 'app/AndroidManifest.xml'], ['app/src/com/cocos/game/AppActivity.java', 'app/src/com/cocos/game/AppActivity.java'], ] as const; for (const [templateRelativePath, outputRelativePath] of requiredFiles) { await copyFileIfMissing( join(templateDir, templateRelativePath), join(outputAndroidRootDir, outputRelativePath), result, ); } const templateResDir = join(templateDir, 'res'); if (await fileExists(templateResDir)) { await copyDirectoryIfMissing(templateResDir, join(outputAndroidRootDir, 'res'), result); } } const commonTemplateDir = await resolveCommonTemplateDir(options.cocosEnginePath); if (commonTemplateDir) { await copyDirectoryIfMissing( commonTemplateDir, join(options.outputDir, 'native/engine/common'), result, ); } } async function applyAndroidNativePatches(options: { outputDir: string; projectDir?: string; runtimeConfig?: ResolvedNativeProviderRuntimeConfig | null; screenOrientation?: string | null; }, result: NativePatchResult) { const androidAppName = await resolveAndroidAppName({ outputDir: options.outputDir, projectDir: options.projectDir, }); const cocosEnginePath = await resolveCocosEnginePath({ outputDir: options.outputDir, projectDir: options.projectDir, }); // Use the orientation passed in directly (e.g. from buildOptions in the extension hook) as the // primary source. Fall back to reading cocos.compile.config.json for CLI / non-hook callers. const androidScreenOrientation = options.screenOrientation ?? await resolveAndroidScreenOrientation(options.outputDir); await ensureAndroidTemplateHostFiles({ outputDir: options.outputDir, cocosEnginePath, }, result); const gradleNativeDir = await resolveAndroidGradleNativeDir({ outputDir: options.outputDir, projectDir: options.projectDir, }); const exportedProjectBuildGradlePath = join(options.outputDir, 'proj/build.gradle'); if (await fileExists(exportedProjectBuildGradlePath)) { const exportedProjectBuildGradle = await readFile(exportedProjectBuildGradlePath, 'utf8'); await writeIfChanged( exportedProjectBuildGradlePath, patchAndroidRootBuildGradle(exportedProjectBuildGradle), result, ); } const androidRootDirs = Array.from(new Set([ join(options.outputDir, 'native/engine/android'), join(options.projectDir ?? options.outputDir, 'native/engine/android'), ])); for (const androidRootDir of androidRootDirs) { const rootBuildGradlePath = join(androidRootDir, 'build.gradle'); if (await fileExists(rootBuildGradlePath)) { const rootBuildGradle = await readFile(rootBuildGradlePath, 'utf8'); await writeIfChanged(rootBuildGradlePath, patchAndroidRootBuildGradle(rootBuildGradle), result); } const buildGradlePath = join(androidRootDir, 'app/build.gradle'); if (await fileExists(buildGradlePath)) { const buildGradle = await readFile(buildGradlePath, 'utf8'); await writeIfChanged(buildGradlePath, patchAndroidBuildGradle(buildGradle), result); } const appActivityPath = await findAndroidAppActivity(androidRootDir); if (appActivityPath) { const appActivity = await readFile(appActivityPath, 'utf8'); const patched = appActivityPath.endsWith('.kt') ? patchAndroidAppActivityKotlin(appActivity) : patchAndroidAppActivityJava(appActivity); await writeIfChanged(appActivityPath, patched, result); } const androidManifestPath = join(androidRootDir, 'app/AndroidManifest.xml'); if (await fileExists(androidManifestPath)) { const androidManifest = await readFile(androidManifestPath, 'utf8'); let nextAndroidManifest = androidManifest; if (options.runtimeConfig?.provider === 'admob' && options.runtimeConfig.shared.appId) { nextAndroidManifest = patchAndroidManifest(nextAndroidManifest, options.runtimeConfig.shared.appId); } if (androidScreenOrientation) { nextAndroidManifest = patchAndroidAppScreenOrientation(nextAndroidManifest, androidScreenOrientation); } await writeIfChanged( androidManifestPath, nextAndroidManifest, result, ); } const stringsXmlPath = join(androidRootDir, 'res/values/strings.xml'); const nextStringsXml = await fileExists(stringsXmlPath) ? patchAndroidStringsXml(await readFile(stringsXmlPath, 'utf8'), androidAppName) : patchAndroidStringsXml('', androidAppName); await writeOrCreateIfChanged(stringsXmlPath, nextStringsXml, result); await removeLegacyAndroidJavaSources(androidRootDir, result); } const gradlePropertiesPath = join(options.outputDir, 'proj/gradle.properties'); if (await fileExists(gradlePropertiesPath)) { const gradleProperties = await readFile(gradlePropertiesPath, 'utf8'); await writeIfChanged( gradlePropertiesPath, patchAndroidGradleProperties(gradleProperties, { minimumSdkVersion: resolveMinimumAndroidSdkVersion(options.runtimeConfig), appName: androidAppName, cocosEnginePath, outputDir: options.outputDir, nativeDir: gradleNativeDir, }), result, ); } if (cocosEnginePath) { const cfgCmakePath = join(options.outputDir, 'proj/cfg.cmake'); const nextCfgCmake = await fileExists(cfgCmakePath) ? patchCfgCmake(await readFile(cfgCmakePath, 'utf8'), cocosEnginePath) : patchCfgCmake('', cocosEnginePath); await writeOrCreateIfChanged(cfgCmakePath, nextCfgCmake, result); } } async function resolveIosPatchRoots(options: { outputDir: string; projectDir?: string; }) { const candidates = [ options.projectDir ? join(options.projectDir, 'native/engine/ios') : null, join(options.outputDir, 'native/engine/ios'), ].filter((value): value is string => Boolean(value)); const roots: string[] = []; for (const candidate of candidates) { if (!await hasIosPatchTargets(candidate)) { continue; } if (!roots.includes(candidate)) { roots.push(candidate); } } return roots; } async function applyIosNativePatches(options: { outputDir: string; projectDir?: string; runtimeConfig?: ResolvedNativeProviderRuntimeConfig | null; }, result: NativePatchResult) { const patchRoots = await resolveIosPatchRoots(options); result.iosPatchRoots.push(...patchRoots); if (patchRoots.length === 0) { throw new Error('[rewarded-ads] No iOS template root with patch targets was found.'); } const projectInfo = await resolveExportedIosProjectInfo(options.outputDir); if (!projectInfo) { throw new Error('[rewarded-ads] Unable to resolve exported iOS app target from project.pbxproj.'); } result.iosResolvedTargetName = projectInfo.targetName; if (options.projectDir) { const legacyPackageDir = join(options.projectDir, 'native/engine/ios/RewardedAdsSPM'); if (await fileExists(legacyPackageDir)) { await rm(legacyPackageDir, { recursive: true, force: true }); result.patchedFiles.push(legacyPackageDir); } } const localPodRoot = options.projectDir ? join(options.projectDir, 'native/engine/ios/PRAdsKit') : null; if (!localPodRoot) { throw new Error('[rewarded-ads] Unable to resolve local PRAdsKit path.'); } const podfilePath = join(options.outputDir, 'proj', 'Podfile'); const podRelativePath = relative(dirname(podfilePath), localPodRoot).replace(/\\/g, '/'); await writeOrCreateIfChanged( podfilePath, renderIosPodfile(projectInfo.targetName, podRelativePath), result, ); for (const patchRoot of patchRoots) { const cmakePath = join(patchRoot, 'CMakeLists.txt'); if (await fileExists(cmakePath)) { const cmake = await readFile(cmakePath, 'utf8'); await writeIfChanged(cmakePath, removeIosCMakeIntegration(cmake), result); } const appDelegatePath = join(patchRoot, 'AppDelegate.mm'); if (!await fileExists(appDelegatePath)) { throw new Error(`[rewarded-ads] AppDelegate.mm is missing at ${appDelegatePath}`); } const appDelegate = await readFile(appDelegatePath, 'utf8'); await writeIfChanged( appDelegatePath, patchIosAppDelegate(removeIosAppDelegateIntegration(appDelegate)), result, ); const infoPlistPath = join(patchRoot, 'Info.plist'); if (!await fileExists(infoPlistPath)) { throw new Error(`[rewarded-ads] Info.plist is missing at ${infoPlistPath}`); } const appId = options.runtimeConfig?.shared.appId; if (!appId) { throw new Error('[rewarded-ads] Missing iOS AdMob app id required for Info.plist patch.'); } const infoPlist = await readFile(infoPlistPath, 'utf8'); await writeIfChanged( infoPlistPath, patchIosInfoPlist(infoPlist, appId), result, ); } } export async function applyNativeProjectPatches(options: { outputDir: string; projectDir?: string; targetPlatform: RewardedAdTargetPlatform; runtimeConfig?: ResolvedNativeProviderRuntimeConfig | null; screenOrientation?: string | null; }): Promise { const outputDir = resolvePath(options.outputDir); const projectDir = options.projectDir ? resolvePath(options.projectDir) : undefined; const result: NativePatchResult = { patchedFiles: [], skippedFiles: [], warnings: [], iosPatchRoots: [], iosResolvedTargetName: null, }; if (options.targetPlatform === 'android') { await applyAndroidNativePatches({ outputDir, projectDir, runtimeConfig: options.runtimeConfig, screenOrientation: options.screenOrientation, }, result); return result; } if (options.targetPlatform === 'ios') { await applyIosNativePatches({ outputDir, projectDir, runtimeConfig: options.runtimeConfig, }, result); return result; } return result; }