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;
}