@mpgd/cli 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/capacitor-shell-starter.d.ts +30 -0
  2. package/dist/capacitor-shell-starter.js +243 -11
  3. package/dist/index.js +170 -11
  4. package/dist/legal-site-output.d.ts +18 -0
  5. package/dist/legal-site-output.js +46 -1
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +1663 -899
  8. package/dist/native-deploy-run.js +4 -0
  9. package/dist/native-shell-store-policy.d.ts +123 -0
  10. package/dist/native-shell-store-policy.js +816 -0
  11. package/dist/release-state.d.ts +2 -0
  12. package/dist/release-state.js +4 -2
  13. package/dist/testflight-submission.d.ts +9 -0
  14. package/dist/testflight-submission.js +69 -0
  15. package/package.json +17 -17
  16. package/templates/lite-overlay/agent/acceptance.md +1 -1
  17. package/templates/lite-overlay/agent/brief.md +1 -1
  18. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  19. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  20. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  21. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +12 -6
  22. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  23. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  24. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  25. package/templates/notifications-overlay/common/src/platform/notifications.ts +17 -3
  26. package/templates/phaser-game/README.md +72 -0
  27. package/templates/phaser-game/agent/acceptance.md +4 -1
  28. package/templates/phaser-game/agent/brief.md +14 -4
  29. package/templates/phaser-game/gitignore +11 -0
  30. package/templates/phaser-game/legal/delete-account.html +133 -0
  31. package/templates/phaser-game/legal/privacy.html +111 -12
  32. package/templates/phaser-game/legal/support.html +1 -0
  33. package/templates/phaser-game/legal/terms.html +1 -0
  34. package/templates/phaser-game/mpgd.game.json +3 -0
  35. package/templates/phaser-game/src/env.d.ts +6 -0
  36. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  37. package/templates/phaser-game/src/main.ts +69 -8
  38. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  39. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  40. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  41. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  42. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  43. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  44. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  45. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  46. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -137,4 +137,34 @@ export declare function androidManifestRemovesExactAlarm(manifest: string): bool
137
137
  * returned unchanged.
138
138
  */
139
139
  export declare function applyAndroidManifestInexactAlarms(manifest: string): string;
140
+ /** The kit's messaging service that renders Android advertising pushes with the opt-out action. */
141
+ export declare const androidAdvertisingRendererService = "dev.mpgd.capacitor.AdvertisingMessagingService";
142
+ /** The messaging service `@capacitor-firebase/messaging` 8.x declares, which the renderer replaces. */
143
+ export declare const androidMessagingPluginService = "io.capawesome.capacitorjs.plugins.firebase.messaging.MessagingService";
144
+ /** The meta-data the renderer's service declares, which `getAdvertisingPushRenderer` reads. */
145
+ export declare const androidAdvertisingRendererMetaData = "dev.mpgd.notifications.advertising_renderer";
146
+ /** The renderer's service element, then the removal of the plugin's service. */
147
+ export declare const androidAdvertisingRendererEntries: readonly string[];
148
+ /**
149
+ * Whether an Android manifest declares the kit's advertising renderer as its
150
+ * messaging service, with the messaging event and its meta-data, and removes
151
+ * the plugin's service. Comments do not count.
152
+ */
153
+ export declare function androidManifestRendersAdvertising(manifest: string): boolean;
154
+ /**
155
+ * Declares the kit's advertising renderer as the shell's messaging service
156
+ * inside `<application>` (`androidAdvertisingRendererEntries`), binding the
157
+ * tools namespace on the manifest root when missing. Each part is added only
158
+ * when it is missing, so a manifest that has one keeps it once; a renderer
159
+ * service that is declared but incomplete is left for the game to fix (the
160
+ * initializer warns). A manifest this cannot place it in (no `<application>`
161
+ * with a closing tag) is returned unchanged.
162
+ */
163
+ export declare function applyAndroidManifestAdvertisingRenderer(manifest: string): string;
164
+ /**
165
+ * The initializer's warning for a game-owned Android manifest without the
166
+ * kit's advertising renderer: the manifest lines, the provider wiring, and
167
+ * the deployment switch. `undefined` when the manifest has the renderer.
168
+ */
169
+ export declare function androidAdvertisingRendererWarning(manifest: string): string | undefined;
140
170
  export {};
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { DOMParser } from '@xmldom/xmldom';
7
7
  import { satisfies, valid, validRange } from 'semver';
8
8
  import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsCapacitorTemplateSupportFiles, notificationsCapacitorTemplateSupportImporters, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, sourceReferencesRelativeModule, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
9
+ import { addIosAppResource, applyAndroidManifestAdId, applyAndroidManifestOrientation, applyIosInfoPlistOrientation, applyIosInfoPlistStorePolicy, applyIosProjectDeviceFamily, iosAppPrivacyManifestFile, iosAppPrivacyManifestXml, isRecord, readGameAdvertisingIdPolicy, readGameNativeOrientation, readIosDeviceFamily, shellUsesAdMob, } from './native-shell-store-policy.js';
9
10
  import { isNonPublicServiceHostname } from './production-target-readiness.js';
10
11
  import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
11
12
  const shellPath = 'apps/mobile-capacitor';
@@ -153,6 +154,9 @@ export function planCapacitorShellStarter(input) {
153
154
  const cliDevelopmentDependencies = requireObject(cliPackage.devDependencies, 'CLI dev dependencies');
154
155
  const typescriptVersion = requireString(cliDevelopmentDependencies.typescript, 'CLI TypeScript version');
155
156
  const targetMap = requireObject(targets.targets, 'targets');
157
+ // Fail before anything is installed or generated when the store-facing
158
+ // native settings are invalid; they are applied after `cap add`.
159
+ readNativeShellSettings(gameRoot);
156
160
  for (const plugin of capacitorShellProviderPlugins) {
157
161
  if (providerIds.includes(plugin.id) && targetMap[plugin.platform] === undefined) {
158
162
  throw new Error(`--providers ${plugin.id} installs the ${plugin.platform} store plugin into the native `
@@ -653,7 +657,12 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
653
657
  materializeAndroidBackupPolicy(plan.gameRoot);
654
658
  if (plan.notificationPlugins.length > 0) {
655
659
  materializeAndroidInexactAlarms(plan.gameRoot);
660
+ materializeAndroidAdvertisingRenderer(plan.gameRoot);
656
661
  }
662
+ materializeAndroidStorePolicy(plan.gameRoot);
663
+ }
664
+ if (platform === 'ios' && generatedNow) {
665
+ materializeIosStorePolicy(plan.gameRoot);
657
666
  }
658
667
  if (platform === 'ios') {
659
668
  materializeIosArchiveScheme(plan.gameRoot);
@@ -800,6 +809,89 @@ export function applyAndroidManifestBackupPolicy(manifest) {
800
809
  applicationTag = applicationTag.replace(/^<application\b/u, `<application${attributes}`);
801
810
  return `${manifest.slice(0, root.index)}${rootTag}${manifest.slice(root.index + root[0].length, application.index)}${applicationTag}${manifest.slice(application.index + application[0].length)}`;
802
811
  }
812
+ /**
813
+ * Store-facing Info.plist keys for a freshly generated iOS project only
814
+ * (`applyIosInfoPlistStorePolicy`); an existing game-owned project is left
815
+ * alone and the production build reports what it lacks.
816
+ */
817
+ function materializeIosStorePolicy(gameRoot) {
818
+ const settings = readNativeShellSettings(gameRoot);
819
+ const plistFile = safeDestination(gameRoot, `${shellPath}/ios/App/App/Info.plist`);
820
+ if (existsSync(plistFile) && lstatSync(plistFile).isFile()) {
821
+ const source = readFileSync(plistFile, 'utf8');
822
+ let next = applyIosInfoPlistStorePolicy(source);
823
+ if (settings.orientation !== undefined) {
824
+ next = applyIosInfoPlistOrientation(next, settings.orientation);
825
+ }
826
+ if (next !== source) {
827
+ writeFileSync(plistFile, next);
828
+ }
829
+ }
830
+ const projectFile = safeDestination(gameRoot, `${shellPath}/ios/App/App.xcodeproj/project.pbxproj`);
831
+ const source = readFileSync(projectFile, 'utf8');
832
+ let next = settings.iosDeviceFamily === undefined
833
+ ? source
834
+ : applyIosProjectDeviceFamily(source, settings.iosDeviceFamily);
835
+ // The app-level privacy manifest joins the App group and Resources phase of
836
+ // the Capacitor-generated layout; any other layout is left to the game and
837
+ // reported by the build.
838
+ const manifestFile = safeDestination(gameRoot, `${shellPath}/ios/App/App/${iosAppPrivacyManifestFile}`);
839
+ const withManifest = addIosAppResource(next, iosAppPrivacyManifestFile);
840
+ if (withManifest === undefined) {
841
+ console.warn(`[mpgd] The generated iOS project has an unexpected layout; add ${iosAppPrivacyManifestFile} `
842
+ + 'to the App target yourself.');
843
+ }
844
+ else {
845
+ if (!existsSync(manifestFile)) {
846
+ writeFileSync(manifestFile, iosAppPrivacyManifestXml, { flag: 'wx' });
847
+ }
848
+ next = withManifest;
849
+ }
850
+ if (next !== source) {
851
+ writeFileSync(projectFile, next);
852
+ }
853
+ }
854
+ /**
855
+ * Orientation of a freshly generated Android project's launcher activity,
856
+ * `android:appCategory="game"`, and the advertising ID permission (declared
857
+ * when the shell installs AdMob and mpgd.game.json does not set
858
+ * `ads.admob.advertisingId` to "removed", removed otherwise); an existing
859
+ * project is only reported by the production build.
860
+ */
861
+ function materializeAndroidStorePolicy(gameRoot) {
862
+ const manifestFile = safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`);
863
+ if (!existsSync(manifestFile) || !lstatSync(manifestFile).isFile()) {
864
+ return;
865
+ }
866
+ const source = readFileSync(manifestFile, 'utf8');
867
+ const settings = readNativeShellSettings(gameRoot);
868
+ const shellPackageFile = path.join(gameRoot, shellPath, 'package.json');
869
+ const usesAds = existsSync(shellPackageFile) && shellUsesAdMob(readJsonObject(shellPackageFile));
870
+ const next = applyAndroidManifestAdId(applyAndroidManifestOrientation(source, settings.orientation ?? 'any'), usesAds && settings.advertisingId !== 'removed');
871
+ if (next !== source) {
872
+ writeFileSync(manifestFile, next);
873
+ }
874
+ }
875
+ /**
876
+ * The game's design orientation and advertising ID policy (mpgd.game.json)
877
+ * and iOS device family (ios target metadata).
878
+ */
879
+ function readNativeShellSettings(gameRoot) {
880
+ const gameConfigFile = path.join(gameRoot, 'mpgd.game.json');
881
+ const gameConfig = existsSync(gameConfigFile)
882
+ ? parseJsonObject(readFileSync(gameConfigFile, 'utf8'), 'mpgd.game.json')
883
+ : undefined;
884
+ const orientation = readGameNativeOrientation(gameConfig);
885
+ const advertisingId = readGameAdvertisingIdPolicy(gameConfig);
886
+ const targets = readJsonObject(path.join(gameRoot, 'mpgd.targets.json'));
887
+ const ios = isRecord(targets.targets) ? targets.targets.ios : undefined;
888
+ const iosDeviceFamily = readIosDeviceFamily(isRecord(ios) ? ios.metadata : undefined);
889
+ return {
890
+ ...(orientation === undefined ? {} : { orientation }),
891
+ ...(iosDeviceFamily === undefined ? {} : { iosDeviceFamily }),
892
+ advertisingId,
893
+ };
894
+ }
803
895
  function materializeIosArchiveScheme(gameRoot) {
804
896
  const schemeRelative = `${shellPath}/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme`;
805
897
  const schemeFile = safeDestination(gameRoot, schemeRelative);
@@ -2600,6 +2692,132 @@ export function applyAndroidManifestInexactAlarms(manifest) {
2600
2692
  const head = `${manifest.slice(0, root.index)}${rootTag}${manifest.slice(root.index + root[0].length, close)}`;
2601
2693
  return `${head.trimEnd()}${newline} ${androidInexactAlarmPermission}${newline}${manifest.slice(close)}`;
2602
2694
  }
2695
+ /** The kit's messaging service that renders Android advertising pushes with the opt-out action. */
2696
+ export const androidAdvertisingRendererService = 'dev.mpgd.capacitor.AdvertisingMessagingService';
2697
+ /** The messaging service `@capacitor-firebase/messaging` 8.x declares, which the renderer replaces. */
2698
+ export const androidMessagingPluginService = 'io.capawesome.capacitorjs.plugins.firebase.messaging.MessagingService';
2699
+ /** The meta-data the renderer's service declares, which `getAdvertisingPushRenderer` reads. */
2700
+ export const androidAdvertisingRendererMetaData = 'dev.mpgd.notifications.advertising_renderer';
2701
+ /**
2702
+ * The manifest entries that make the kit's renderer the shell's one Firebase
2703
+ * messaging service (notifications RFC "Capacitor"). Android hands a
2704
+ * messaging event to one service, so the plugin's own is removed from the
2705
+ * merge; the kit's forwards every message it does not render, and every new
2706
+ * token, to the plugin.
2707
+ */
2708
+ const androidAdvertisingRendererServiceEntry = [
2709
+ `<service android:name="${androidAdvertisingRendererService}" android:exported="false">`,
2710
+ ' <intent-filter>',
2711
+ ' <action android:name="com.google.firebase.MESSAGING_EVENT" />',
2712
+ ' </intent-filter>',
2713
+ ` <meta-data android:name="${androidAdvertisingRendererMetaData}" android:value="true" />`,
2714
+ '</service>',
2715
+ ];
2716
+ const androidMessagingPluginServiceRemoval = `<service android:name="${androidMessagingPluginService}" tools:node="remove" />`;
2717
+ /** The renderer's service element, then the removal of the plugin's service. */
2718
+ export const androidAdvertisingRendererEntries = [
2719
+ ...androidAdvertisingRendererServiceEntry,
2720
+ androidMessagingPluginServiceRemoval,
2721
+ ];
2722
+ /** `text` as a literal inside a regular expression. */
2723
+ function regexLiteral(text) {
2724
+ // Only syntax characters: the `u` flag refuses an escaped `-` or letter.
2725
+ return text.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&');
2726
+ }
2727
+ /** An attribute `name="value"` (or `'value'`) inside a start tag; both are literal. */
2728
+ function xmlAttributeLookahead(name, value) {
2729
+ const attribute = `\\s${regexLiteral(name)}\\s*=\\s*`;
2730
+ const literal = regexLiteral(value);
2731
+ return `(?=[^>]*${attribute}(?:"${literal}"|'${literal}'))`;
2732
+ }
2733
+ /** The manifest's `<service>` element for `name`, self-closed or with a body, or `undefined`. */
2734
+ function androidServiceElement(code, name) {
2735
+ return new RegExp(`<service\\b${xmlAttributeLookahead('android:name', name)}[^>]*?(?:/>|>[\\s\\S]*?</service>)`, 'u').exec(code)?.[0];
2736
+ }
2737
+ /** Whether the manifest removes the messaging plugin's own service from the merge. */
2738
+ function androidManifestRemovesMessagingPluginService(code) {
2739
+ return new RegExp(`<service\\b${xmlAttributeLookahead('android:name', androidMessagingPluginService)}`
2740
+ + `${xmlAttributeLookahead('tools:node', 'remove')}[^>]*>`, 'u').test(code);
2741
+ }
2742
+ /**
2743
+ * Whether an Android manifest declares the kit's advertising renderer as its
2744
+ * messaging service, with the messaging event and its meta-data, and removes
2745
+ * the plugin's service. Comments do not count.
2746
+ */
2747
+ export function androidManifestRendersAdvertising(manifest) {
2748
+ const code = withoutXmlComments(manifest);
2749
+ const renderer = androidServiceElement(code, androidAdvertisingRendererService);
2750
+ return renderer !== undefined
2751
+ && renderer.includes('com.google.firebase.MESSAGING_EVENT')
2752
+ && new RegExp(`<meta-data\\b${xmlAttributeLookahead('android:name', androidAdvertisingRendererMetaData)}`
2753
+ + `${xmlAttributeLookahead('android:value', 'true')}`, 'u').test(renderer)
2754
+ && androidManifestRemovesMessagingPluginService(code);
2755
+ }
2756
+ /**
2757
+ * Declares the kit's advertising renderer as the shell's messaging service
2758
+ * inside `<application>` (`androidAdvertisingRendererEntries`), binding the
2759
+ * tools namespace on the manifest root when missing. Each part is added only
2760
+ * when it is missing, so a manifest that has one keeps it once; a renderer
2761
+ * service that is declared but incomplete is left for the game to fix (the
2762
+ * initializer warns). A manifest this cannot place it in (no `<application>`
2763
+ * with a closing tag) is returned unchanged.
2764
+ */
2765
+ export function applyAndroidManifestAdvertisingRenderer(manifest) {
2766
+ if (androidManifestRendersAdvertising(manifest)) {
2767
+ return manifest;
2768
+ }
2769
+ const code = withoutXmlComments(manifest);
2770
+ const missing = [
2771
+ ...(androidServiceElement(code, androidAdvertisingRendererService) === undefined
2772
+ ? androidAdvertisingRendererServiceEntry
2773
+ : []),
2774
+ ...(androidManifestRemovesMessagingPluginService(code) ? [] : [androidMessagingPluginServiceRemoval]),
2775
+ ];
2776
+ const root = /<manifest\b[^>]*?>/u.exec(code);
2777
+ const close = code.lastIndexOf('</application>');
2778
+ const application = /<application\b[^>]*?>/u.exec(code);
2779
+ if (missing.length === 0 || root === null || application === null || close === -1
2780
+ || root[0].endsWith('/>') || application[0].endsWith('/>') || close < application.index
2781
+ || application.index < root.index) {
2782
+ return manifest;
2783
+ }
2784
+ const newline = manifest.includes('\r\n') ? '\r\n' : '\n';
2785
+ let rootTag = manifest.slice(root.index, root.index + root[0].length);
2786
+ if (!/\sxmlns:tools\s*=/u.test(rootTag)) {
2787
+ rootTag = rootTag.replace(/^<manifest\b/u, `<manifest xmlns:tools="${androidToolsNamespace}"`);
2788
+ }
2789
+ const head = `${manifest.slice(0, root.index)}${rootTag}${manifest.slice(root.index + root[0].length, close)}`;
2790
+ const entries = missing.map((line) => `${newline} ${line}`).join('');
2791
+ return `${head.trimEnd()}${entries}${newline} ${manifest.slice(close)}`;
2792
+ }
2793
+ /**
2794
+ * The initializer's warning for a game-owned Android manifest without the
2795
+ * kit's advertising renderer: the manifest lines, the provider wiring, and
2796
+ * the deployment switch. `undefined` when the manifest has the renderer.
2797
+ */
2798
+ export function androidAdvertisingRendererWarning(manifest) {
2799
+ if (androidManifestRendersAdvertising(manifest)) {
2800
+ return undefined;
2801
+ }
2802
+ return `${shellPath}/android/app/src/main/AndroidManifest.xml does not declare the kit's advertising `
2803
+ + 'renderer, so under the Korean rules this shell receives no advertising push (the backend '
2804
+ + 'sends Android advertising only to a shell that shows the opt-out action). To render them, '
2805
+ + `add inside <application> (with xmlns:tools on <manifest>): ${androidAdvertisingRendererEntries.join(' ')}; `
2806
+ + 'pass advertisingRenderer: readCapacitorAdvertisingPushRenderer to the Capacitor provider; '
2807
+ + 'and set shellRendersAdvertising: true in notifications/deployment.json before mapping an '
2808
+ + 'advertising topic to android (see docs/NOTIFICATIONS.md).';
2809
+ }
2810
+ function materializeAndroidAdvertisingRenderer(gameRoot) {
2811
+ const manifestFile = safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`);
2812
+ if (!existsSync(manifestFile) || !lstatSync(manifestFile).isFile()) {
2813
+ return;
2814
+ }
2815
+ const source = readFileSync(manifestFile, 'utf8');
2816
+ const next = applyAndroidManifestAdvertisingRenderer(source);
2817
+ if (next !== source) {
2818
+ writeFileSync(manifestFile, next);
2819
+ }
2820
+ }
2603
2821
  function materializeAndroidInexactAlarms(gameRoot) {
2604
2822
  const manifestFile = safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`);
2605
2823
  if (!existsSync(manifestFile) || !lstatSync(manifestFile).isFile()) {
@@ -2668,10 +2886,10 @@ function planNotificationsProvider(input) {
2668
2886
  + `@mpgd/adapter-capacitor ${String(adapter.version)} does not export; upgrade the game's kit `
2669
2887
  + 'packages (mpgd kit upgrade) first.');
2670
2888
  }
2671
- // The starter's provider module also imports the settings opener, which
2672
- // an adapter older than this CLI's notifications starter does not have.
2673
- // The subpath's declared types file (dist/notifications.d.ts today), and
2674
- // the module it re-exports the opener from.
2889
+ // The starter's provider module also imports the settings opener and the
2890
+ // advertising renderer read, which an adapter older than this CLI's
2891
+ // notifications starter does not have. The subpath's declared types file
2892
+ // (dist/notifications.d.ts today), and the modules it re-exports them from.
2675
2893
  const adapterRoot = path.join(gameRoot, 'node_modules/@mpgd/adapter-capacitor');
2676
2894
  const declaredTypes = adapterExports['./notifications'];
2677
2895
  const typesPath = typeof declaredTypes === 'object' && declaredTypes !== null
@@ -2679,14 +2897,22 @@ function planNotificationsProvider(input) {
2679
2897
  ? declaredTypes.types
2680
2898
  : './dist/notifications.d.ts';
2681
2899
  const adapterTypes = readExisting(path.join(adapterRoot, typesPath));
2682
- const openerTypes = readExisting(path.join(adapterRoot, path.dirname(typesPath), 'notifications-settings.d.ts'));
2683
- // Refused only when the declarations show the opener is absent: an
2900
+ const helperTypes = ['notifications-settings.d.ts', 'notifications-renderer.d.ts']
2901
+ .map((file) => readExisting(path.join(adapterRoot, path.dirname(typesPath), file)) ?? '')
2902
+ .join('\n');
2903
+ // Refused only when the declarations show a helper is absent: an
2684
2904
  // `export *` the check cannot follow passes, and the build then decides.
2685
- if (adapterTypes !== undefined && !/\bexport\s*\*/u.test(adapterTypes)
2686
- && !`${adapterTypes}\n${openerTypes ?? ''}`.includes('openCapacitorNotificationSettings')) {
2687
- throw new Error(`The game's @mpgd/adapter-capacitor ${String(adapter.version)} has no `
2688
- + 'openCapacitorNotificationSettings, which the notifications starter uses; upgrade the '
2689
- + "game's kit packages (mpgd kit upgrade) first.");
2905
+ // The advertising renderer read came after the opener (AR1).
2906
+ for (const helper of [
2907
+ 'openCapacitorNotificationSettings',
2908
+ 'readCapacitorAdvertisingPushRenderer',
2909
+ ]) {
2910
+ if (adapterTypes !== undefined && !/\bexport\s*\*/u.test(adapterTypes)
2911
+ && !`${adapterTypes}\n${helperTypes}`.includes(helper)) {
2912
+ throw new Error(`The game's @mpgd/adapter-capacitor ${String(adapter.version)} has no `
2913
+ + `${helper}, which the notifications starter uses; upgrade the `
2914
+ + "game's kit packages (mpgd kit upgrade) first.");
2915
+ }
2690
2916
  }
2691
2917
  // The adapter's other peers (Capacitor core) must accept the game's
2692
2918
  // installed copies too; the plugins get their own message below.
@@ -2831,6 +3057,12 @@ function planNotificationsProvider(input) {
2831
3057
  + '@capacitor/local-notifications adds; reminders are inexact, so add '
2832
3058
  + `${androidInexactAlarmPermission} to the manifest.`);
2833
3059
  }
3060
+ const rendererWarning = androidManifest === undefined
3061
+ ? undefined
3062
+ : androidAdvertisingRendererWarning(androidManifest);
3063
+ if (rendererWarning !== undefined) {
3064
+ warnings.push(rendererWarning);
3065
+ }
2834
3066
  if (readExisting(safeDestination(gameRoot, `${shellPath}/android/app/google-services.json`)) === undefined) {
2835
3067
  warnings.push(`Android push needs ${shellPath}/android/app/google-services.json from the Firebase console `
2836
3068
  + '(see the mpgd-kit Firebase guide); without it the push half fails closed.');
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import completion from '@gunshi/plugin-completion';
@@ -12,7 +12,7 @@ import { buildAssetPacks } from './asset-pack-build.js';
12
12
  import { verifyAssetPackDelivery } from './asset-pack-verify.js';
13
13
  import { buildAssetPackTarget } from './asset-pack-target.js';
14
14
  import { publishAssetPacks } from './asset-pack-publish.js';
15
- import { assertLegalProjectPath, publishLegalSiteFiles } from './legal-site-output.js';
15
+ import { assertLegalProjectPath, publishLegalSiteFiles, resolveAppAdsTxt, } from './legal-site-output.js';
16
16
  import { assertAssetPackTargetPolicy } from '@mpgd/target-config/asset-packs';
17
17
  import { materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
18
18
  import { doctorNativeDeployment, initializeDeployConfig, parseDeployTargets, planNativeDeployment, readNativeDeploymentPlan, writeNativeDeploymentPlan, } from './deploy-planning.js';
@@ -973,7 +973,19 @@ const gameCommand = defineI18n({
973
973
  const defaultLegalDir = 'legal';
974
974
  const defaultLegalOutDir = 'artifacts/legal-site';
975
975
  const legalPageSlugs = ['privacy', 'support', 'terms'];
976
+ /**
977
+ * Built when its source exists: the account deletion request page whose URL
978
+ * Google Play's Data safety form asks for. A missing one is warned about.
979
+ */
980
+ const optionalLegalPageSlugs = ['delete-account'];
976
981
  const legalPageFileList = legalPageSlugs.map((slug) => `${slug}.html`).join(', ');
982
+ const optionalLegalPageFileList = optionalLegalPageSlugs.map((slug) => `${slug}.html`).join(', ');
983
+ /** The account deletion page's slug, whose URL the Play Data safety form asks for. */
984
+ const deleteAccountLegalSlug = optionalLegalPageSlugs[0];
985
+ /** Where a legal page is published: `<out-dir>/<slug>/index.html`, served at `/<slug>/`. */
986
+ function legalPageOutputFile(outDir, slug) {
987
+ return path.join(outDir, slug, 'index.html');
988
+ }
977
989
  function acceptGame(input) {
978
990
  if (input.skipTargetBuild && !input.skipTargetSmoke) {
979
991
  throw new Error('Use --skip-target-smoke when --skip-target-build is set.');
@@ -1540,6 +1552,11 @@ const legalCommand = defineI18n({
1540
1552
  check: false,
1541
1553
  });
1542
1554
  console.info(`Built legal site: ${result.outDir}`);
1555
+ if (result.deleteAccountPath !== undefined) {
1556
+ console.info(`Account deletion page: ${result.deleteAccountPath} on the legal site. Enter its full URL `
1557
+ + 'in Play Console > App content > Data safety > Delete account URL, and link it from '
1558
+ + 'the privacy policy.');
1559
+ }
1543
1560
  },
1544
1561
  }),
1545
1562
  check: defineI18n({
@@ -2760,7 +2777,7 @@ function legalArgs() {
2760
2777
  type: 'string',
2761
2778
  required: false,
2762
2779
  default: defaultLegalDir,
2763
- description: `Directory containing ${legalPageFileList}.`,
2780
+ description: `Directory containing ${legalPageFileList} (and optionally ${optionalLegalPageFileList}).`,
2764
2781
  },
2765
2782
  'out-dir': {
2766
2783
  type: 'string',
@@ -2773,8 +2790,8 @@ function legalArgs() {
2773
2790
  function legalArgResources() {
2774
2791
  return {
2775
2792
  'legal-dir': {
2776
- en: `Directory containing ${legalPageFileList}.`,
2777
- ko: `${legalPageFileList} 이 있는 디렉터리.`,
2793
+ en: `Directory containing ${legalPageFileList} (and optionally ${optionalLegalPageFileList}).`,
2794
+ ko: `${legalPageFileList} (선택: ${optionalLegalPageFileList}) 이 있는 디렉터리.`,
2778
2795
  },
2779
2796
  'out-dir': {
2780
2797
  en: 'Output directory for the static legal site.',
@@ -2794,8 +2811,58 @@ function buildLegalSite(input) {
2794
2811
  assertLegalProjectPath('legal-dir', legalDir);
2795
2812
  assertLegalProjectPath('out-dir', outDir);
2796
2813
  const outputs = createLegalSiteOutputs(legalDir, outDir);
2814
+ const deleteAccountPath = `/${deleteAccountLegalSlug}/`;
2815
+ const deleteAccountBuilt = outputs.some((output) => output.file === legalPageOutputFile(outDir, deleteAccountLegalSlug));
2816
+ const deleteAccountResult = deleteAccountBuilt ? { deleteAccountPath } : {};
2817
+ if (!deleteAccountBuilt) {
2818
+ const linking = outputs
2819
+ // Any quoting of the link (or a full URL ending in the path).
2820
+ .filter((output) => output.content.includes(deleteAccountPath))
2821
+ .map((output) => path.relative(process.cwd(), output.file));
2822
+ const source = path.join(path.relative(process.cwd(), legalDir) || '.', `${deleteAccountLegalSlug}.html`);
2823
+ if (linking.length > 0) {
2824
+ // The starter's privacy, support, and terms pages link to it: a site
2825
+ // without it would publish dead deletion links, so it is required then.
2826
+ throw new Error(`No ${source}, but these pages link to ${deleteAccountPath}: ${linking.join(', ')}. `
2827
+ + 'Google Play needs that page (Play Console > App content > Data safety): copy '
2828
+ + 'legal/delete-account.html from a game created with the current kit, or remove the links.');
2829
+ }
2830
+ console.warn(`No ${source}: `
2831
+ + 'Google Play needs a web page where players request account deletion without reinstalling '
2832
+ + 'the game (Play Console > App content > Data safety). Copy legal/delete-account.html from a '
2833
+ + 'game created with the current kit.');
2834
+ }
2835
+ // app-ads.txt and the optional pages are stale once their source is gone.
2836
+ const optionalOutputs = [
2837
+ { file: path.join(outDir, 'app-ads.txt'), source: 'app-ads.txt' },
2838
+ ...optionalLegalPageSlugs.map((slug) => ({
2839
+ file: legalPageOutputFile(outDir, slug),
2840
+ source: `${slug}.html`,
2841
+ })),
2842
+ ];
2843
+ const orphans = optionalOutputs.filter(({ file }) => {
2844
+ if (outputs.some((output) => output.file === file)) {
2845
+ return false;
2846
+ }
2847
+ const stat = lstatIfExists(file);
2848
+ if (stat === undefined) {
2849
+ return false;
2850
+ }
2851
+ assertLegalProjectPath('out-dir', file);
2852
+ if (!stat.isFile()) {
2853
+ throw new Error(`Legal site output is not a regular file: ${file}`);
2854
+ }
2855
+ return true;
2856
+ });
2857
+ warnLegalPlaceholders(outputs);
2797
2858
  if (input.check) {
2798
2859
  assertLegalProjectPath('out-dir', outDir);
2860
+ if (orphans.length > 0) {
2861
+ throw new Error([
2862
+ 'Legal site output is stale. Run "mpgd legal build".',
2863
+ ...orphans.map(({ file, source }) => `- ${path.relative(process.cwd(), file)} (no ${source} source)`),
2864
+ ].join('\n'));
2865
+ }
2799
2866
  const stale = outputs.filter((output) => {
2800
2867
  assertLegalProjectPath('out-dir', output.file);
2801
2868
  if (!existsSync(output.file)) {
@@ -2811,18 +2878,47 @@ function buildLegalSite(input) {
2811
2878
  }
2812
2879
  return {
2813
2880
  outDir,
2881
+ ...deleteAccountResult,
2814
2882
  };
2815
2883
  }
2816
2884
  publishLegalSiteFiles(outDir, outputs);
2885
+ for (const { file } of orphans) {
2886
+ // Checked again right before removing: the directory may have changed.
2887
+ assertLegalProjectPath('out-dir', file);
2888
+ rmSync(file, { force: true });
2889
+ if (path.dirname(file) !== outDir) {
2890
+ // The page's own directory, once empty.
2891
+ try {
2892
+ rmdirSync(path.dirname(file));
2893
+ }
2894
+ catch (error) {
2895
+ // Not empty, or already gone: leave it. Anything else is reported.
2896
+ const code = error.code;
2897
+ if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'ENOENT' && code !== 'ENOTDIR') {
2898
+ console.warn(`Could not remove the empty legal page directory ${path.relative(process.cwd(), path.dirname(file))}: ${error instanceof Error ? error.message : String(error)}`);
2899
+ }
2900
+ }
2901
+ }
2902
+ }
2817
2903
  return {
2818
2904
  outDir,
2905
+ ...deleteAccountResult,
2819
2906
  };
2820
2907
  }
2821
2908
  function createLegalSiteOutputs(legalDir, outDir) {
2822
2909
  assertLegalProjectPath('legal-dir', legalDir);
2823
2910
  assertLegalProjectPath('out-dir', outDir);
2824
2911
  const sourceRoot = toTemplatePath(path.relative(process.cwd(), legalDir) || '.');
2825
- const outputs = legalPageSlugs.map((slug) => {
2912
+ const optionalSlugs = optionalLegalPageSlugs.filter((slug) => {
2913
+ const sourceFile = path.join(legalDir, `${slug}.html`);
2914
+ if (lstatIfExists(sourceFile) === undefined) {
2915
+ return false;
2916
+ }
2917
+ assertLegalProjectPath('legal-dir', sourceFile);
2918
+ return true;
2919
+ });
2920
+ const slugs = [...legalPageSlugs, ...optionalSlugs];
2921
+ const outputs = slugs.map((slug) => {
2826
2922
  const sourceFile = path.join(legalDir, `${slug}.html`);
2827
2923
  // existsSync and readFileSync follow a page symlink. Check the file
2828
2924
  // itself, not only the legal directory, immediately before reading it.
@@ -2832,12 +2928,16 @@ function createLegalSiteOutputs(legalDir, outDir) {
2832
2928
  }
2833
2929
  assertLegalProjectPath('legal-dir', sourceFile);
2834
2930
  return {
2835
- file: path.join(outDir, slug, 'index.html'),
2931
+ file: legalPageOutputFile(outDir, slug),
2836
2932
  content: normalizeLegalHtml(readFileSync(sourceFile, 'utf8'), sourceFile),
2837
2933
  };
2838
2934
  });
2935
+ const appAdsTxt = readLegalAppAdsTxt(legalDir);
2839
2936
  return [
2840
2937
  ...outputs,
2938
+ ...(appAdsTxt === undefined
2939
+ ? []
2940
+ : [{ file: path.join(outDir, 'app-ads.txt'), content: appAdsTxt }]),
2841
2941
  {
2842
2942
  file: path.join(outDir, '_headers'),
2843
2943
  content: [
@@ -2852,9 +2952,7 @@ function createLegalSiteOutputs(legalDir, outDir) {
2852
2952
  file: path.join(outDir, '_redirects'),
2853
2953
  content: [
2854
2954
  '/ /support/ 302',
2855
- '/privacy /privacy/ 301',
2856
- '/support /support/ 301',
2857
- '/terms /terms/ 301',
2955
+ ...slugs.map((slug) => `/${slug} /${slug}/ 301`),
2858
2956
  '',
2859
2957
  ].join('\n'),
2860
2958
  },
@@ -2862,7 +2960,7 @@ function createLegalSiteOutputs(legalDir, outDir) {
2862
2960
  file: path.join(outDir, 'legal-site.json'),
2863
2961
  content: `${JSON.stringify({
2864
2962
  version: 1,
2865
- pages: legalPageSlugs.map((slug) => ({
2963
+ pages: slugs.map((slug) => ({
2866
2964
  slug,
2867
2965
  path: `/${slug}/`,
2868
2966
  source: `${sourceRoot}/${slug}.html`,
@@ -2871,6 +2969,58 @@ function createLegalSiteOutputs(legalDir, outDir) {
2871
2969
  },
2872
2970
  ];
2873
2971
  }
2972
+ /**
2973
+ * `app-ads.txt` for the legal site root: `<legal-dir>/app-ads.txt`, else the
2974
+ * AdMob line for `ads.admob.publisherId` in the project's `mpgd.game.json`
2975
+ * (the project root is the working directory, as for the legal sources).
2976
+ */
2977
+ function readLegalAppAdsTxt(legalDir) {
2978
+ const ownAppAds = path.join(legalDir, 'app-ads.txt');
2979
+ const ownExists = lstatIfExists(ownAppAds) !== undefined;
2980
+ if (ownExists) {
2981
+ assertLegalProjectPath('legal-dir', ownAppAds);
2982
+ }
2983
+ const gameConfigFile = path.join(process.cwd(), 'mpgd.game.json');
2984
+ let gameConfig;
2985
+ if (lstatIfExists(gameConfigFile) !== undefined) {
2986
+ assertLegalProjectPath('mpgd.game.json', gameConfigFile);
2987
+ try {
2988
+ gameConfig = JSON.parse(readFileSync(gameConfigFile, 'utf8'));
2989
+ }
2990
+ catch (error) {
2991
+ throw new Error(`mpgd.game.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2992
+ }
2993
+ }
2994
+ return resolveAppAdsTxt({
2995
+ ...(ownExists ? { ownFile: readFileSync(ownAppAds, 'utf8') } : {}),
2996
+ ...(gameConfig === undefined ? {} : { gameConfig }),
2997
+ });
2998
+ }
2999
+ function lstatIfExists(file) {
3000
+ try {
3001
+ return lstatSync(file);
3002
+ }
3003
+ catch (error) {
3004
+ const code = error.code;
3005
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
3006
+ return undefined;
3007
+ }
3008
+ throw error;
3009
+ }
3010
+ }
3011
+ /**
3012
+ * The starter's legal pages mark what only the game owner can write with
3013
+ * `[REPLACE: ...]`. Building with them is allowed (a new game builds its
3014
+ * Pages host at once), but the store listing must not link to them.
3015
+ */
3016
+ function warnLegalPlaceholders(outputs) {
3017
+ const pending = outputs
3018
+ .filter((output) => output.content.includes('[REPLACE:'))
3019
+ .map((output) => path.relative(process.cwd(), output.file));
3020
+ if (pending.length > 0) {
3021
+ console.warn(`Legal pages still contain [REPLACE: ...] markers; fill them in before linking the site from a store listing: ${pending.join(', ')}`);
3022
+ }
3023
+ }
2874
3024
  function normalizeLegalHtml(content, sourceFile) {
2875
3025
  if (!/^<!doctype html>/iu.test(content) || !/<html[\s>]/iu.test(content)) {
2876
3026
  throw new Error(`${sourceFile} must be a complete HTML document.`);
@@ -2916,10 +3066,19 @@ function applyLiteTemplateOverlay(baseFiles, overlayFiles) {
2916
3066
  if (file.relativePath === 'mpgd.targets.json') {
2917
3067
  return [{ ...file, content: liteTemplateTargets(file.content) }];
2918
3068
  }
3069
+ if (file.relativePath === 'mpgd.game.json') {
3070
+ return [{ ...file, content: liteTemplateGameConfig(file.content) }];
3071
+ }
2919
3072
  return [file];
2920
3073
  });
2921
3074
  return [...merged, ...overlay.values()];
2922
3075
  }
3076
+ /** The lite tap game is orientation-agnostic; the Phaser template's landscape lock does not apply. */
3077
+ function liteTemplateGameConfig(content) {
3078
+ const config = JSON.parse(content);
3079
+ config.native = { ...config.native, orientation: 'any' };
3080
+ return `${JSON.stringify(config, null, 2)}\n`;
3081
+ }
2923
3082
  function liteTemplateTargets(content) {
2924
3083
  const value = JSON.parse(content);
2925
3084
  const reddit = value.targets.reddit;
@@ -10,3 +10,21 @@ export interface LegalSiteFile {
10
10
  export declare function assertLegalProjectPath(label: string, resolved: string): void;
11
11
  /** Stage the site beside the destination, then publish with rename. */
12
12
  export declare function publishLegalSiteFiles(outDir: string, outputs: readonly LegalSiteFile[]): void;
13
+ /**
14
+ * Google's AdMob line for `app-ads.txt` (`f08c47fec0942fa0` is Google's
15
+ * certification authority ID). AdMob shows the exact line for an account
16
+ * under Apps > View all apps > app-ads.txt; use a game-owned
17
+ * `legal/app-ads.txt` instead when it differs or mediation partners add lines.
18
+ */
19
+ export declare function admobAppAdsTxtLine(publisherId: string): string;
20
+ /**
21
+ * The `app-ads.txt` the legal site serves at its root, or undefined: a
22
+ * game-owned `<legal-dir>/app-ads.txt` verbatim, else the AdMob line for
23
+ * `ads.admob.publisherId` in the game's `mpgd.game.json`. Store listings
24
+ * name this site as the developer website (Play) or marketing URL (App
25
+ * Store), which is where ad buyers look for the file.
26
+ */
27
+ export declare function resolveAppAdsTxt(input: {
28
+ readonly ownFile?: string;
29
+ readonly gameConfig?: unknown;
30
+ }): string | undefined;