import { access, readFile, writeFile, mkdir, rm } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; import { dirname, join, resolve as resolvePath } from 'node:path'; import type { RewardedAdsConfig, RewardedAdTargetPlatform } from '../../core/src/types.ts'; import { prepareGeneratedArtifacts } from './generated-entry.ts'; import { materializeGeneratedArtifacts } from './materialize.ts'; import { applyNativeProjectPatches } from './native-patcher.ts'; import { renderNativeProviderArtifacts, resolveNativeProviderRuntimeConfig, } from './native-provider.ts'; const COCOS_PLATFORM_MAP = { android: 'android', ios: 'ios', wechatgame: 'wechat-mini-game', 'bytedance-mini-game': 'douyin-mini-game', } as const; async function fileExists(path: string) { try { await access(path, fsConstants.F_OK); return true; } catch { return false; } } function renderIosLocalPodspec() { return `Pod::Spec.new do |s| s.name = 'PRAdsKit' s.version = '1.0.0' s.summary = 'Rewarded ads bridge for Cocos Creator' s.description = 'Local rewarded ads bridge wrapper that owns Google Mobile Ads SDK integration.' s.homepage = 'https://example.invalid/PRAdsKit' s.license = { :type => 'MIT' } s.author = { 'Tinivo' => 'devnull@example.invalid' } s.platform = :ios, '13.0' s.source = { :path => '.' } s.source_files = 'Sources/PRAdsKit/**/*.{h,m,mm}' s.public_header_files = 'Sources/PRAdsKit/include/*.h' s.dependency 'Google-Mobile-Ads-SDK' s.dependency 'GoogleUserMessagingPlatform' s.frameworks = 'AppTrackingTransparency' end `; } function renderIosLocalPodHeader() { return `#import FOUNDATION_EXPORT void TinivoInstallRewardedAds(UIViewController *rootViewController); FOUNDATION_EXPORT void TinivoResetRewardedAds(void); `; } function renderIosLocalPodSource(runtimeConfig: ReturnType) { const providerConfigJson = JSON.stringify({ provider: runtimeConfig.provider, platform: runtimeConfig.targetPlatform, shared: runtimeConfig.shared, placements: runtimeConfig.placements, }).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `#import "PRAdsKit.h" #import #import #import #import typedef void (^OnScriptEventListener)(NSString *); @interface JsbBridgeWrapper : NSObject + (instancetype)sharedInstance; - (void)addScriptEventListener:(NSString *)eventName listener:(OnScriptEventListener)listener; - (void)dispatchEventToScript:(NSString *)eventName arg:(NSString *)arg; @end static NSString *const kTinivoRewardedRequestEvent = @"tinivo.rewarded.request"; static NSString *const kTinivoRewardedResponseEvent = @"tinivo.rewarded.response"; static NSString *const kTinivoPrivacyRequestEvent = @"tinivo.privacy.request"; static NSString *const kTinivoPrivacyResponseEvent = @"tinivo.privacy.response"; static NSDictionary *TinivoRewardedProviderConfig(void) { static NSDictionary *config = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSData *data = [@"${providerConfigJson}" dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; config = [json isKindOfClass:[NSDictionary class]] ? json : @{}; }); return config ?: @{}; } @interface RewardedAdsBridgeInternal : NSObject @property (nonatomic, strong) UIViewController *rootViewController; @property (nonatomic, strong) NSMutableDictionary *rewardedAds; @property (nonatomic, strong) NSMutableDictionary *requestIdsByPlacement; @property (nonatomic, strong) NSMutableDictionary *placementKeysByRewardedAd; @property (nonatomic, strong) NSMutableSet *rewardedPlacements; @property (nonatomic, copy) OnScriptEventListener requestListener; @property (nonatomic, copy) OnScriptEventListener privacyRequestListener; @property (nonatomic, assign) BOOL installed; @property (nonatomic, assign) BOOL didCompleteConsentInfoUpdate; - (void)installWithRootViewController:(UIViewController *)rootViewController; - (void)handleRequest:(NSString *)payload; - (void)handlePrivacyRequest:(NSString *)payload; @end @implementation RewardedAdsBridgeInternal - (instancetype)init { self = [super init]; if (self) { _rewardedAds = [[NSMutableDictionary alloc] init]; _requestIdsByPlacement = [[NSMutableDictionary alloc] init]; _placementKeysByRewardedAd = [[NSMutableDictionary alloc] init]; _rewardedPlacements = [[NSMutableSet alloc] init]; } return self; } - (void)installWithRootViewController:(UIViewController *)rootViewController { self.rootViewController = rootViewController; if (self.installed) { return; } self.installed = YES; [GADMobileAds.sharedInstance startWithCompletionHandler:nil]; [self requestConsentInfoUpdate]; __weak typeof(self) weakSelf = self; self.requestListener = [^(NSString *arg) { [weakSelf handleRequest:arg]; } copy]; [[JsbBridgeWrapper sharedInstance] addScriptEventListener:kTinivoRewardedRequestEvent listener:self.requestListener]; self.privacyRequestListener = [^(NSString *arg) { [weakSelf handlePrivacyRequest:arg]; } copy]; [[JsbBridgeWrapper sharedInstance] addScriptEventListener:kTinivoPrivacyRequestEvent listener:self.privacyRequestListener]; } - (void)handleRequest:(NSString *)payload { NSData *jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *request = jsonData ? [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:nil] : nil; NSString *command = request[@"command"] ?: @""; NSString *requestId = request[@"requestId"] ?: @""; NSString *placement = request[@"placement"] ?: @""; if ([command isEqualToString:@"preload"]) { [self preloadPlacement:placement requestId:requestId]; return; } if ([command isEqualToString:@"show"]) { [self showPlacement:placement requestId:requestId]; return; } if ([command isEqualToString:@"reset"]) { [self resetPlacement:placement]; return; } [self dispatchErrorWithRequestId:requestId placement:placement code:@"unknown" message:@"Unknown rewarded command"]; } - (void)handlePrivacyRequest:(NSString *)payload { NSData *jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *request = jsonData ? [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:nil] : nil; NSString *command = request[@"command"] ?: @""; NSString *requestId = request[@"requestId"] ?: @""; if ([command isEqualToString:@"getState"]) { [self dispatchPrivacyStateWithRequestId:requestId]; return; } if ([command isEqualToString:@"showOptions"]) { [self presentPrivacyOptionsWithRequestId:requestId]; return; } [self dispatchPrivacyErrorWithRequestId:requestId code:@"unknown" message:@"Unknown privacy command"]; } - (void)requestConsentInfoUpdate { UMPRequestParameters *parameters = [[UMPRequestParameters alloc] init]; [[UMPConsentInformation sharedInstance] requestConsentInfoUpdateWithParameters:parameters completionHandler:^(NSError * _Nullable error) { if (error != nil) { self.didCompleteConsentInfoUpdate = NO; NSLog(@"[PRAdsKit][Privacy] consent info update failed code=%ld message=%@", (long)error.code, error.localizedDescription ?: @""); return; } self.didCompleteConsentInfoUpdate = YES; NSLog(@"[PRAdsKit][Privacy] consent info updated status=%ld privacyOptionsRequirementStatus=%ld", (long)[UMPConsentInformation sharedInstance].consentStatus, (long)[UMPConsentInformation sharedInstance].privacyOptionsRequirementStatus); UIViewController *presentingViewController = self.rootViewController; if (presentingViewController == nil) { return; } [UMPConsentForm loadAndPresentIfRequiredFromViewController:presentingViewController completionHandler:^(NSError * _Nullable formError) { if (formError != nil) { NSLog(@"[PRAdsKit][Privacy] consent form failed code=%ld message=%@", (long)formError.code, formError.localizedDescription ?: @""); return; } NSLog(@"[PRAdsKit][Privacy] consent form completed"); }]; }]; } - (BOOL)canShowPrivacyOptions { return self.didCompleteConsentInfoUpdate && [UMPConsentInformation sharedInstance].privacyOptionsRequirementStatus == UMPPrivacyOptionsRequirementStatusRequired; } - (NSDictionary *)privacyStatePayload { BOOL canShowPrivacyOptions = [self canShowPrivacyOptions]; BOOL requiresConsentFlow = [UMPConsentInformation sharedInstance].consentStatus == UMPConsentStatusRequired; NSString *status = self.didCompleteConsentInfoUpdate ? (canShowPrivacyOptions ? @"available" : @"unavailable") : @"unavailable"; return @{ @"supported": @YES, @"canShowPrivacyOptions": @(canShowPrivacyOptions), @"requiresConsentFlow": @(requiresConsentFlow), @"status": status, }; } - (void)dispatchPrivacyStateWithRequestId:(NSString *)requestId { [self dispatchPrivacyResponse:@{ @"requestId": requestId ?: @"", @"command": @"getState", @"result": [self privacyStatePayload], }]; } - (void)presentPrivacyOptionsWithRequestId:(NSString *)requestId { if (![self canShowPrivacyOptions] || self.rootViewController == nil) { NSLog(@"[PRAdsKit][Privacy] privacy options unavailable"); [self dispatchPrivacyResponse:@{ @"requestId": requestId ?: @"", @"command": @"showOptions", @"result": @{ @"shown": @NO, @"errorCode": @"privacyOptionsUnavailable", @"message": @"Privacy options unavailable", }, }]; return; } [UMPConsentForm presentPrivacyOptionsFormFromViewController:self.rootViewController completionHandler:^(NSError * _Nullable formError) { if (formError != nil) { NSLog(@"[PRAdsKit][Privacy] privacy options failed code=%ld message=%@", (long)formError.code, formError.localizedDescription ?: @""); [self dispatchPrivacyErrorWithRequestId:requestId code:@"showOptionsFailed" message:formError.localizedDescription ?: @"Privacy options failed"]; return; } NSLog(@"[PRAdsKit][Privacy] privacy options shown"); [self dispatchPrivacyResponse:@{ @"requestId": requestId ?: @"", @"command": @"showOptions", @"result": @{ @"shown": @YES, }, }]; }]; } - (void)preloadPlacement:(NSString *)placement requestId:(NSString *)requestId { NSString *adUnitId = [self adUnitIdForPlacement:placement]; if (adUnitId.length == 0) { [self dispatchErrorWithRequestId:requestId placement:placement code:@"configInvalid" message:@"Missing ad unit id"]; return; } self.requestIdsByPlacement[placement] = requestId; [GADRewardedAd loadWithAdUnitID:adUnitId request:[GADRequest request] completionHandler:^(GADRewardedAd * _Nullable rewardedAd, NSError * _Nullable error) { if (error != nil || rewardedAd == nil) { NSLog(@"[PRAdsKit][Rewarded] load failed placement=%@ adUnitId=%@ code=%ld domain=%@ message=%@ userInfo=%@", placement ?: @"", adUnitId ?: @"", (long)error.code, error.domain ?: @"", error.localizedDescription ?: @"", error.userInfo ?: @{}); [self dispatchErrorWithRequestId:self.requestIdsByPlacement[placement] ?: @"" placement:placement code:@"loadFailed" message:error.localizedDescription ?: @"Rewarded ad failed to load"]; return; } rewardedAd.fullScreenContentDelegate = self; GADRewardedAd *existingRewardedAd = self.rewardedAds[placement]; if (existingRewardedAd != nil) { [self.placementKeysByRewardedAd removeObjectForKey:[self placementKeyForRewardedAd:existingRewardedAd]]; } self.rewardedAds[placement] = rewardedAd; self.placementKeysByRewardedAd[[self placementKeyForRewardedAd:rewardedAd]] = placement; [self dispatchResponse:@{ @"requestId": self.requestIdsByPlacement[placement] ?: @"", @"command": @"preload", @"placement": placement ?: @"", }]; }]; } - (void)showPlacement:(NSString *)placement requestId:(NSString *)requestId { GADRewardedAd *rewardedAd = self.rewardedAds[placement]; if (rewardedAd == nil) { [self dispatchErrorWithRequestId:requestId placement:placement code:@"notReady" message:@"Rewarded ad is not ready"]; return; } [self.rewardedPlacements removeObject:placement]; self.requestIdsByPlacement[placement] = requestId; [rewardedAd presentFromRootViewController:self.rootViewController userDidEarnRewardHandler:^{ [self.rewardedPlacements addObject:placement]; }]; } - (void)resetPlacement:(NSString *)placement { if (placement.length == 0) { [self.rewardedAds removeAllObjects]; [self.requestIdsByPlacement removeAllObjects]; [self.placementKeysByRewardedAd removeAllObjects]; [self.rewardedPlacements removeAllObjects]; return; } GADRewardedAd *rewardedAd = self.rewardedAds[placement]; if (rewardedAd != nil) { [self.placementKeysByRewardedAd removeObjectForKey:[self placementKeyForRewardedAd:rewardedAd]]; } [self.rewardedAds removeObjectForKey:placement]; [self.requestIdsByPlacement removeObjectForKey:placement]; [self.rewardedPlacements removeObject:placement]; } - (void)adDidDismissFullScreenContent:(id)ad { NSString *placement = [self placementForRewardedAd:(GADRewardedAd *)ad]; NSString *requestId = self.requestIdsByPlacement[placement] ?: @""; BOOL earned = [self.rewardedPlacements containsObject:placement]; [self.rewardedPlacements removeObject:placement]; [self dispatchResponse:@{ @"requestId": requestId, @"command": @"show", @"placement": placement ?: @"", @"result": @{ @"completed": @YES, @"cancelled": @(!earned), @"earned": @(earned), }, }]; GADRewardedAd *loadedAd = self.rewardedAds[placement]; if (loadedAd != nil) { [self.placementKeysByRewardedAd removeObjectForKey:[self placementKeyForRewardedAd:loadedAd]]; } [self.rewardedAds removeObjectForKey:placement]; [self preloadPlacement:placement requestId:@""]; } - (void)ad:(id)ad didFailToPresentFullScreenContentWithError:(NSError *)error { NSString *placement = [self placementForRewardedAd:(GADRewardedAd *)ad]; NSString *requestId = self.requestIdsByPlacement[placement] ?: @""; NSLog(@"[PRAdsKit][Rewarded] show failed placement=%@ code=%ld domain=%@ message=%@", placement ?: @"", (long)error.code, error.domain ?: @"", error.localizedDescription ?: @""); [self dispatchErrorWithRequestId:requestId placement:placement code:@"showFailed" message:error.localizedDescription ?: @"Rewarded ad failed to show"]; } - (NSString *)adUnitIdForPlacement:(NSString *)placement { NSDictionary *placements = TinivoRewardedProviderConfig()[@"placements"]; return placements[placement][@"adUnitId"] ?: @""; } - (NSString *)placementKeyForRewardedAd:(GADRewardedAd *)rewardedAd { return [NSString stringWithFormat:@"%p", rewardedAd]; } - (NSString *)placementForRewardedAd:(GADRewardedAd *)rewardedAd { return self.placementKeysByRewardedAd[[self placementKeyForRewardedAd:rewardedAd]] ?: @""; } - (void)dispatchErrorWithRequestId:(NSString *)requestId placement:(NSString *)placement code:(NSString *)code message:(NSString *)message { [self dispatchResponse:@{ @"requestId": requestId ?: @"", @"command": @"error", @"placement": placement ?: @"", @"error": @{ @"code": code ?: @"unknown", @"message": message ?: @"unknown error", }, }]; } - (void)dispatchResponse:(NSDictionary *)payload { NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; [[JsbBridgeWrapper sharedInstance] dispatchEventToScript:kTinivoRewardedResponseEvent arg:content]; } - (void)dispatchPrivacyResponse:(NSDictionary *)payload { NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; [[JsbBridgeWrapper sharedInstance] dispatchEventToScript:kTinivoPrivacyResponseEvent arg:content]; } - (void)dispatchPrivacyErrorWithRequestId:(NSString *)requestId code:(NSString *)code message:(NSString *)message { [self dispatchPrivacyResponse:@{ @"requestId": requestId ?: @"", @"command": @"error", @"error": @{ @"code": code ?: @"unknown", @"message": message ?: @"unknown error", }, }]; } @end static RewardedAdsBridgeInternal *gTinivoRewardedAdsBridge = nil; void TinivoInstallRewardedAds(UIViewController *rootViewController) { if (gTinivoRewardedAdsBridge != nil) { return; } gTinivoRewardedAdsBridge = [[RewardedAdsBridgeInternal alloc] init]; [gTinivoRewardedAdsBridge installWithRootViewController:rootViewController]; } void TinivoResetRewardedAds(void) { gTinivoRewardedAdsBridge = nil; } `; } export function resolveTargetPlatformFromCocosBuildOptions(options: { platform: string; }): RewardedAdTargetPlatform { const targetPlatform = COCOS_PLATFORM_MAP[options.platform as keyof typeof COCOS_PLATFORM_MAP]; if (!targetPlatform) { throw new Error(`Unsupported Cocos build platform: ${options.platform}`); } return targetPlatform; } async function materializeExtensionSkeleton(options: { outputDir: string; extensionPackageJson: unknown; extensionMainSource: string; extensionBuilderSource: string; extensionHooksSource: string; }) { const extensionDir = join(options.outputDir, 'cocos-extension'); const packageJsonPath = join(extensionDir, 'package.json'); const distMainPath = join(extensionDir, 'dist', 'main.js'); const distBuilderPath = join(extensionDir, 'dist', 'builder.js'); const distHooksPath = join(extensionDir, 'dist', 'hooks.js'); await mkdir(dirname(distHooksPath), { recursive: true }); await writeFile(packageJsonPath, `${JSON.stringify(options.extensionPackageJson, null, 2)}\n`, 'utf8'); await writeFile(distMainPath, options.extensionMainSource, 'utf8'); await writeFile(distBuilderPath, options.extensionBuilderSource, 'utf8'); await writeFile(distHooksPath, options.extensionHooksSource, 'utf8'); return [packageJsonPath, distMainPath, distBuilderPath, distHooksPath]; } async function materializeNativeTemplateFiles(options: { outputDir: string; projectDir: string; templateTargets: Array<{ templatePath: string; outputPath: string }>; }) { const files: string[] = []; for (const target of options.templateTargets) { const templateFilePath = join(dirname(new URL(import.meta.url).pathname), '..', target.templatePath); const content = await readFile(templateFilePath, 'utf8'); const outputRoots = target.outputPath.startsWith('native/engine/') && options.projectDir !== options.outputDir ? [options.outputDir, options.projectDir] : [options.outputDir]; for (const outputRoot of outputRoots) { const outputPath = join(outputRoot, target.outputPath); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, content, 'utf8'); files.push(outputPath); } } return files; } async function materializeNativeProviderFiles(options: { outputDir: string; projectDir: string; runtimeConfig: ReturnType | null; }) { if (!options.runtimeConfig) { return []; } if (options.runtimeConfig.targetPlatform !== 'android' && options.runtimeConfig.targetPlatform !== 'ios') { return []; } const artifacts = renderNativeProviderArtifacts(options.runtimeConfig); const files: string[] = []; for (const artifact of artifacts) { const outputRoots = artifact.outputPath.startsWith('native/engine/') && options.projectDir !== options.outputDir ? [options.outputDir, options.projectDir] : [options.outputDir]; for (const outputRoot of outputRoots) { const outputPath = join(outputRoot, artifact.outputPath); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, artifact.content, 'utf8'); files.push(outputPath); } } return files; } async function materializeIosLocalPodFiles(options: { projectDir: string; runtimeConfig: ReturnType | null; }) { if (!options.runtimeConfig || options.runtimeConfig.targetPlatform !== 'ios') { return []; } const packageRoot = join(options.projectDir, 'native/engine/ios/PRAdsKit'); const podspecPath = join(packageRoot, 'PRAdsKit.podspec'); const headerPath = join(packageRoot, 'Sources/PRAdsKit/include/PRAdsKit.h'); const sourcePath = join(packageRoot, 'Sources/PRAdsKit/PRAdsKit.mm'); await mkdir(dirname(headerPath), { recursive: true }); await writeFile(podspecPath, renderIosLocalPodspec(), 'utf8'); await writeFile(headerPath, renderIosLocalPodHeader(), 'utf8'); await writeFile(sourcePath, renderIosLocalPodSource(options.runtimeConfig), 'utf8'); return [podspecPath, headerPath, sourcePath]; } export async function prepareBuildArtifactsFromConfigFile(options: { adsConfigPath: string; cocosBuildOptions: { platform: string; }; outputDir: string; projectDir?: string; extensionName?: string; screenOrientation?: string | null; }) { const adsConfigPath = resolvePath(options.adsConfigPath); const outputDir = resolvePath(options.outputDir); const projectDir = resolvePath(options.projectDir ?? dirname(adsConfigPath)); const raw = await readFile(adsConfigPath, 'utf8'); const config = JSON.parse(raw) as RewardedAdsConfig; const targetPlatform = resolveTargetPlatformFromCocosBuildOptions(options.cocosBuildOptions); if (targetPlatform === 'ios') { const legacySpmPackageDir = join(projectDir, 'native/engine/ios/RewardedAdsSPM'); if (await fileExists(legacySpmPackageDir)) { await rm(legacySpmPackageDir, { recursive: true, force: true }); } } const artifacts = prepareGeneratedArtifacts({ extensionName: options.extensionName, targetPlatform, config, }); const generatedFilesResult = await materializeGeneratedArtifacts({ outputDir, artifacts, }); const extensionFiles = await materializeExtensionSkeleton({ outputDir, extensionPackageJson: artifacts.extensionPackageJson, extensionMainSource: artifacts.extensionMainSource, extensionBuilderSource: artifacts.extensionBuilderSource, extensionHooksSource: artifacts.extensionHooksSource, }); const nativeTemplateFiles = artifacts.nativeInjectionPlan ? await materializeNativeTemplateFiles({ outputDir, projectDir, templateTargets: artifacts.nativeInjectionPlan.templateTargets, }) : []; const runtimeConfig = targetPlatform === 'android' || targetPlatform === 'ios' ? resolveNativeProviderRuntimeConfig({ targetPlatform, config, }) : null; const nativeProviderFiles = await materializeNativeProviderFiles({ outputDir, projectDir, runtimeConfig, }); const localPodFiles = await materializeIosLocalPodFiles({ projectDir, runtimeConfig, }); const nativePatchResult = await applyNativeProjectPatches({ outputDir, projectDir, targetPlatform, runtimeConfig, screenOrientation: options.screenOrientation, }); const iosLocalPodDirectory = targetPlatform === 'ios' ? join(projectDir, 'native/engine/ios/PRAdsKit') : null; const resolvedIosTargetName = targetPlatform === 'ios' ? nativePatchResult.iosResolvedTargetName ?? null : null; return { targetPlatform, selection: artifacts.selection, generatedFiles: generatedFilesResult.files, extensionFiles, nativeTemplateFiles, nativeProviderFiles, localPodFiles, nativePatchResult, nativeInjectionPlan: artifacts.nativeInjectionPlan, iosIntegrationStatus: targetPlatform === 'ios' ? 'pending_pod_install' : null, iosLocalPodDirectory, iosLocalPodspecPath: iosLocalPodDirectory ? join(iosLocalPodDirectory, 'PRAdsKit.podspec') : null, podfilePath: targetPlatform === 'ios' ? join(outputDir, 'proj', 'Podfile') : null, iosManualStep: targetPlatform === 'ios' ? 'cd build/ios/proj && pod install' : null, iosWorkspaceHint: targetPlatform === 'ios' && resolvedIosTargetName ? `Open build/ios/proj/${resolvedIosTargetName}.xcworkspace after pod install` : null, iosDiagnostics: targetPlatform === 'ios' ? [ 'Run pod install before opening the iOS workspace.', 'Open the generated .xcworkspace instead of the .xcodeproj.', ] : null, }; }