@mpgd/cli 0.52.0 → 0.53.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 (22) hide show
  1. package/dist/capacitor-shell-starter.d.ts +22 -0
  2. package/dist/capacitor-shell-starter.js +387 -18
  3. package/dist/index.js +35 -4
  4. package/dist/native-build-info.json +2 -2
  5. package/dist/native-build-target.js +363 -2
  6. package/dist/notifications-starter.d.ts +69 -0
  7. package/dist/notifications-starter.js +513 -0
  8. package/package.json +17 -17
  9. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +75 -0
  10. package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
  11. package/templates/notifications-overlay/common/notifications/topics.json +222 -0
  12. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
  13. package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
  14. package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
  15. package/templates/notifications-overlay/common/src/platform/notifications.ts +131 -0
  16. package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
  17. package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
  18. package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +282 -0
  19. package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
  20. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
  21. package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
  22. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
@@ -67,6 +67,15 @@ export interface CapacitorShellStarterPlan {
67
67
  /** Native store plugins the plan installs into the shell and declares in the game. */
68
68
  readonly providerPlugins: readonly CapacitorShellPlannedProviderPlugin[];
69
69
  /**
70
+ * The notifications plugins (`--providers notifications`) the plan installs
71
+ * into the shell, for Android and iOS, at the game's versions; empty when
72
+ * notifications are not requested.
73
+ */
74
+ readonly notificationPlugins: readonly {
75
+ readonly packageName: string;
76
+ readonly version: string;
77
+ }[];
78
+ /**
70
79
  * Store wiring the plan could not write or confirm: a missing or game-owned
71
80
  * gateway that does not register the plugin, or a store plugin the shell
72
81
  * keeps although it is no longer requested. Printed; game source is never
@@ -115,4 +124,17 @@ export declare function assertAndroidReleaseLauncherIntegrity(shellApp: string):
115
124
  export declare function assertIosReleaseDisplayName(shellApp: string, expectedDisplayName: string): void;
116
125
  export declare function assertIosArchiveScheme(shellApp: string): void;
117
126
  export declare function decodeAndroidStringResource(value: string): string;
127
+ /** The permission line that drops `SCHEDULE_EXACT_ALARM` from the merged Android manifest. */
128
+ export declare const androidInexactAlarmPermission = "<uses-permission android:name=\"android.permission.SCHEDULE_EXACT_ALARM\" tools:node=\"remove\" />";
129
+ /** Whether an Android manifest already drops `SCHEDULE_EXACT_ALARM` from the merge. */
130
+ export declare function androidManifestRemovesExactAlarm(manifest: string): boolean;
131
+ /**
132
+ * Drops `SCHEDULE_EXACT_ALARM`, which `@capacitor/local-notifications` adds
133
+ * to the merged manifest: reminders are inexact (notifications RFC
134
+ * "Reminders"), Android 14 denies the permission to new installs, and Play
135
+ * restricts it to alarm and calendar apps. The tools namespace is bound on
136
+ * the manifest root when missing. A manifest that already drops it is
137
+ * returned unchanged.
138
+ */
139
+ export declare function applyAndroidManifestInexactAlarms(manifest: string): string;
118
140
  export {};
@@ -4,7 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DOMParser } from '@xmldom/xmldom';
7
- import { satisfies, valid } from 'semver';
7
+ import { satisfies, valid, validRange } from 'semver';
8
+ import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
8
9
  import { isNonPublicServiceHostname } from './production-target-readiness.js';
9
10
  import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
10
11
  const shellPath = 'apps/mobile-capacitor';
@@ -239,7 +240,24 @@ export function planCapacitorShellStarter(input) {
239
240
  .filter((plugin) => requestedProviderIds.includes(plugin.id))
240
241
  .map((plugin) => resolveProviderPlugin(gameRoot, gameDependencies, cliDevelopmentDependencies, plugin));
241
242
  const warnings = [];
242
- const gatewayFiles = planProviderGatewayWiring(gameRoot, input.templateRoot, providerPlugins, warnings);
243
+ const storeGatewayFiles = planProviderGatewayWiring(gameRoot, input.templateRoot, providerPlugins, warnings);
244
+ const notifications = requestedProviderIds.includes(notificationsProviderId)
245
+ ? planNotificationsProvider({
246
+ gameRoot,
247
+ templateRoot: input.templateRoot,
248
+ displayName: input.displayName,
249
+ gameDependencies,
250
+ existingShellPackage: packageContent,
251
+ plannedGateways: storeGatewayFiles,
252
+ warnings,
253
+ })
254
+ : undefined;
255
+ // The notifications wiring builds on the store wiring of the same gateway.
256
+ const gatewayFiles = [
257
+ ...storeGatewayFiles.filter((file) => notifications?.files.some((planned) => planned.path === file.path) !== true),
258
+ ...(notifications?.files ?? []),
259
+ ];
260
+ const notificationPlugins = notifications?.shellPlugins ?? [];
243
261
  if (packageContent !== undefined) {
244
262
  const existingShellDependencies = requireObject(parseJsonObject(packageContent, 'existing shell package').dependencies, 'existing shell dependencies');
245
263
  for (const plugin of capacitorShellProviderPlugins) {
@@ -250,6 +268,35 @@ export function planCapacitorShellStarter(input) {
250
268
  + 'shell package and resync if the game stopped selling there.');
251
269
  }
252
270
  }
271
+ if (notifications === undefined) {
272
+ for (const name of notificationsShellPlugins) {
273
+ if (existingShellDependencies[name] !== undefined) {
274
+ warnings.push(`The shell still lists ${name} although ${notificationsProviderId} is no longer `
275
+ + 'requested; the initializer never removes a plugin, so remove it from the shell '
276
+ + 'package and resync if the game stopped sending notifications.');
277
+ }
278
+ }
279
+ }
280
+ }
281
+ if (notifications !== undefined) {
282
+ // The starter exposes notifications on both platforms; the provider still
283
+ // reports its own readiness (no plugin, no Firebase files), which the
284
+ // target config follows. A state the game set itself is kept.
285
+ for (const targetName of ['android', 'ios']) {
286
+ const target = requireObject(targetMap[targetName], `${targetName} target`);
287
+ const integrations = target.integrations === undefined
288
+ ? {}
289
+ : requireObject(target.integrations, `${targetName}.integrations`);
290
+ if (integrations.notifications === undefined) {
291
+ target.integrations = { ...integrations, notifications: 'available' };
292
+ }
293
+ else if (integrations.notifications !== 'available'
294
+ && integrations.notifications !== 'action-required') {
295
+ warnings.push(`mpgd.targets.json keeps ${targetName}.integrations.notifications at `
296
+ + `${String(integrations.notifications)}, which hides notifications from the game; set it `
297
+ + 'to available once the shell is configured.');
298
+ }
299
+ }
253
300
  }
254
301
  const shellDependencies = {
255
302
  '@capacitor/android': capacitorVersion,
@@ -258,6 +305,7 @@ export function planCapacitorShellStarter(input) {
258
305
  '@capacitor/ios': capacitorVersion,
259
306
  '@mpgd/capacitor-game-services': pluginVersion,
260
307
  ...Object.fromEntries(providerPlugins.map((plugin) => [plugin.packageName, plugin.version])),
308
+ ...Object.fromEntries(notificationPlugins.map((plugin) => [plugin.packageName, plugin.version])),
261
309
  };
262
310
  if (packageContent !== undefined) {
263
311
  assertCompatibleExistingPackage(packageContent, shellDependencies, capacitorVersion, typescriptVersion);
@@ -304,7 +352,7 @@ export function planCapacitorShellStarter(input) {
304
352
  typescript: typescriptVersion,
305
353
  },
306
354
  })
307
- : withPackageDependencies(packageContent, providerPlugins),
355
+ : withPackageDependencies(packageContent, [...providerPlugins, ...notificationPlugins]),
308
356
  },
309
357
  {
310
358
  path: `${shellPath}/capacitor.config.ts`,
@@ -331,17 +379,27 @@ export function planCapacitorShellStarter(input) {
331
379
  appId: input.appId,
332
380
  displayName: input.displayName,
333
381
  requestedProviderIds,
334
- ...(providerPlugins.length === 0
382
+ ...(providerPlugins.length === 0 && notificationPlugins.length === 0
335
383
  ? {}
336
384
  : {
337
- providers: Object.fromEntries(providerPlugins.map((plugin) => [
338
- plugin.id,
339
- {
340
- package: plugin.packageName,
341
- version: plugin.version,
342
- platform: plugin.platform,
343
- },
344
- ])),
385
+ providers: {
386
+ ...Object.fromEntries(providerPlugins.map((plugin) => [
387
+ plugin.id,
388
+ {
389
+ package: plugin.packageName,
390
+ version: plugin.version,
391
+ platform: plugin.platform,
392
+ },
393
+ ])),
394
+ ...(notificationPlugins.length === 0
395
+ ? {}
396
+ : {
397
+ [notificationsProviderId]: {
398
+ packages: Object.fromEntries(notificationPlugins.map((plugin) => [plugin.packageName, plugin.version])),
399
+ platforms: ['android', 'ios'],
400
+ },
401
+ }),
402
+ },
345
403
  }),
346
404
  ...(requestedBackendUrl === undefined ? {} : { backendUrl: requestedBackendUrl }),
347
405
  }),
@@ -361,7 +419,12 @@ export function planCapacitorShellStarter(input) {
361
419
  '',
362
420
  'requestedProviderIds is selection metadata, except play-billing and',
363
421
  'storekit: those install the kit store plugin into this shell and the game',
364
- 'and register it with cap sync. A plugin never reports a feature as',
422
+ 'and register it with cap sync; notifications installs',
423
+ '@capacitor-firebase/messaging and @capacitor/local-notifications (never',
424
+ `${conflictingPushPlugin}, which claims the same iOS push handler).`,
425
+ 'Notifications also need the Firebase files (google-services.json,',
426
+ 'GoogleService-Info.plist) and the iOS Push Notifications capability; see',
427
+ 'the mpgd-kit Firebase guide. A plugin never reports a feature as',
365
428
  'available on its own; the gateway registers the store provider, and the',
366
429
  'store answers configuration-required until the plugin and products exist.',
367
430
  'Production builds require a real game-owned backend when grants are enabled.',
@@ -415,16 +478,33 @@ export function planCapacitorShellStarter(input) {
415
478
  // The game bundle imports the plugin from its gateway file, so the game
416
479
  // declares it too, at the shell's exact version; the materializer's game
417
480
  // install picks it up.
418
- const missingGameDependencies = providerPlugins.filter((plugin) => gameDependencies[plugin.packageName] === undefined);
419
- if (missingGameDependencies.length > 0) {
481
+ const missingGameDependencies = [
482
+ ...providerPlugins.map((plugin) => [plugin.packageName, plugin.version]),
483
+ ...Object.entries(notifications?.gameDependencies ?? {}),
484
+ ].filter(([name]) => gameDependencies[name] === undefined);
485
+ const gameScripts = gamePackage.scripts === undefined
486
+ ? {}
487
+ : requireObject(gamePackage.scripts, 'game scripts');
488
+ const validatorPresent = notifications !== undefined
489
+ && (notifications.files.some((file) => file.path === 'tools/validate-notifications.ts')
490
+ || readExisting(safeDestination(gameRoot, 'tools/validate-notifications.ts')) !== undefined);
491
+ const missingScripts = !validatorPresent || gameScripts['validate:notifications'] !== undefined
492
+ ? {}
493
+ : {
494
+ 'validate:notifications': 'ttsx --project tsconfig.tools.json tools/validate-notifications.ts',
495
+ };
496
+ if (missingGameDependencies.length > 0 || Object.keys(missingScripts).length > 0) {
420
497
  safeDestination(gameRoot, 'package.json');
421
498
  requestedFiles.push({
422
499
  path: 'package.json',
423
500
  content: json({
424
501
  ...gamePackage,
502
+ ...(Object.keys(missingScripts).length === 0
503
+ ? {}
504
+ : { scripts: { ...gameScripts, ...missingScripts } }),
425
505
  dependencies: {
426
506
  ...gameDependencies,
427
- ...Object.fromEntries(missingGameDependencies.map((plugin) => [plugin.packageName, plugin.version])),
507
+ ...Object.fromEntries(missingGameDependencies),
428
508
  },
429
509
  }),
430
510
  });
@@ -454,6 +534,7 @@ export function planCapacitorShellStarter(input) {
454
534
  changedFiles: files.map((file) => file.path),
455
535
  nativePlatformsToAdd,
456
536
  providerPlugins,
537
+ notificationPlugins,
457
538
  warnings,
458
539
  };
459
540
  }
@@ -570,6 +651,9 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
570
651
  const manifest = readJsonObject(path.join(plan.gameRoot, shellPath, 'mpgd.native-shell.json'));
571
652
  if (platform === 'android' && generatedNow) {
572
653
  materializeAndroidBackupPolicy(plan.gameRoot);
654
+ if (plan.notificationPlugins.length > 0) {
655
+ materializeAndroidInexactAlarms(plan.gameRoot);
656
+ }
573
657
  }
574
658
  if (platform === 'ios') {
575
659
  materializeIosArchiveScheme(plan.gameRoot);
@@ -588,7 +672,8 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
588
672
  // `cap add` syncs a new project itself; a preserved project needs an explicit
589
673
  // sync so the store plugin's native code joins the shell.
590
674
  for (const platform of ['android', 'ios']) {
591
- if (plan.providerPlugins.some((plugin) => plugin.platform === platform)
675
+ if ((plan.providerPlugins.some((plugin) => plugin.platform === platform)
676
+ || plan.notificationPlugins.length > 0)
592
677
  && !plan.nativePlatformsToAdd.includes(platform)
593
678
  && existsSync(path.join(plan.gameRoot, shellPath, platform))) {
594
679
  runner.run('pnpm', ['--dir', shellPath, 'cap', 'sync', platform], plan.gameRoot);
@@ -2043,7 +2128,10 @@ function assertCompatibleExistingPackage(source, shellDependencies, capacitorVer
2043
2128
  throw new Error('Existing Capacitor shell package must stay private.');
2044
2129
  }
2045
2130
  const dependencies = requireObject(value.dependencies, 'existing shell dependencies');
2046
- const providerPackages = new Set(capacitorShellProviderPlugins.map((plugin) => plugin.packageName));
2131
+ const providerPackages = new Set([
2132
+ ...capacitorShellProviderPlugins.map((plugin) => plugin.packageName),
2133
+ ...notificationsShellPlugins,
2134
+ ]);
2047
2135
  for (const [name, version] of Object.entries(shellDependencies)) {
2048
2136
  // A store plugin the shell does not list yet is added by the plan. One it
2049
2137
  // lists at another version would compile native code that does not match
@@ -2472,6 +2560,287 @@ function planProviderGatewayWiring(gameRoot, templateRoot, plugins, warnings) {
2472
2560
  }
2473
2561
  return [...planned].map(([file, content]) => ({ path: file, content }));
2474
2562
  }
2563
+ /** The permission line that drops `SCHEDULE_EXACT_ALARM` from the merged Android manifest. */
2564
+ export const androidInexactAlarmPermission = '<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" tools:node="remove" />';
2565
+ /** Whether an Android manifest already drops `SCHEDULE_EXACT_ALARM` from the merge. */
2566
+ export function androidManifestRemovesExactAlarm(manifest) {
2567
+ const code = withoutXmlComments(manifest);
2568
+ return /<uses-permission\b[^>]*android:name\s*=\s*"android\.permission\.SCHEDULE_EXACT_ALARM"[^>]*tools:node\s*=\s*"remove"/u
2569
+ .test(code)
2570
+ || /<uses-permission\b[^>]*tools:node\s*=\s*"remove"[^>]*android:name\s*=\s*"android\.permission\.SCHEDULE_EXACT_ALARM"/u
2571
+ .test(code);
2572
+ }
2573
+ /** The manifest with each XML comment blanked to spaces, so offsets stay the same. */
2574
+ function withoutXmlComments(manifest) {
2575
+ return manifest.replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\n]/gu, ' '));
2576
+ }
2577
+ /**
2578
+ * Drops `SCHEDULE_EXACT_ALARM`, which `@capacitor/local-notifications` adds
2579
+ * to the merged manifest: reminders are inexact (notifications RFC
2580
+ * "Reminders"), Android 14 denies the permission to new installs, and Play
2581
+ * restricts it to alarm and calendar apps. The tools namespace is bound on
2582
+ * the manifest root when missing. A manifest that already drops it is
2583
+ * returned unchanged.
2584
+ */
2585
+ export function applyAndroidManifestInexactAlarms(manifest) {
2586
+ if (androidManifestRemovesExactAlarm(manifest)) {
2587
+ return manifest;
2588
+ }
2589
+ const code = withoutXmlComments(manifest);
2590
+ const root = /<manifest\b[^>]*?>/u.exec(code);
2591
+ const close = code.lastIndexOf('</manifest>');
2592
+ if (root === null || close === -1 || root[0].endsWith('/>') || close < root.index) {
2593
+ return manifest;
2594
+ }
2595
+ const newline = manifest.includes('\r\n') ? '\r\n' : '\n';
2596
+ let rootTag = manifest.slice(root.index, root.index + root[0].length);
2597
+ if (!/\sxmlns:tools\s*=/u.test(rootTag)) {
2598
+ rootTag = rootTag.replace(/^<manifest\b/u, `<manifest xmlns:tools="${androidToolsNamespace}"`);
2599
+ }
2600
+ const head = `${manifest.slice(0, root.index)}${rootTag}${manifest.slice(root.index + root[0].length, close)}`;
2601
+ return `${head.trimEnd()}${newline} ${androidInexactAlarmPermission}${newline}${manifest.slice(close)}`;
2602
+ }
2603
+ function materializeAndroidInexactAlarms(gameRoot) {
2604
+ const manifestFile = safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`);
2605
+ if (!existsSync(manifestFile) || !lstatSync(manifestFile).isFile()) {
2606
+ return;
2607
+ }
2608
+ const source = readFileSync(manifestFile, 'utf8');
2609
+ const next = applyAndroidManifestInexactAlarms(source);
2610
+ if (next !== source) {
2611
+ writeFileSync(manifestFile, next);
2612
+ }
2613
+ }
2614
+ /**
2615
+ * Plans `--providers notifications` for the shell and the game: the two
2616
+ * Capacitor plugins at the game's installed versions (or the starter's), the
2617
+ * game's notifications packages, and, when a Capacitor gateway is still the
2618
+ * template's, its notifications wiring with the starter modules it imports.
2619
+ * A game-owned gateway is only checked. Native setup the initializer never
2620
+ * writes (Firebase files, the iOS capability, an existing Android manifest)
2621
+ * is reported as warnings.
2622
+ */
2623
+ function planNotificationsProvider(input) {
2624
+ const { gameRoot, gameDependencies, warnings } = input;
2625
+ const shellDependencies = input.existingShellPackage === undefined
2626
+ ? {}
2627
+ : requireObject(parseJsonObject(input.existingShellPackage, 'existing shell package').dependencies, 'existing shell dependencies');
2628
+ if (gameDependencies[conflictingPushPlugin] !== undefined
2629
+ || shellDependencies[conflictingPushPlugin] !== undefined) {
2630
+ throw new Error(`--providers notifications installs @capacitor-firebase/messaging, which cannot run next to `
2631
+ + `${conflictingPushPlugin}: on iOS both claim Capacitor's one push handler. Remove `
2632
+ + `${conflictingPushPlugin} from the game and the shell first.`);
2633
+ }
2634
+ const versionOf = (name) => {
2635
+ const installedFile = path.join(gameRoot, 'node_modules', name, 'package.json');
2636
+ if (existsSync(installedFile)) {
2637
+ const installed = readJsonObject(installedFile);
2638
+ const version = requireString(installed.version, `installed ${name} version`);
2639
+ if (valid(version) === null) {
2640
+ throw new Error(`${name} must resolve to a concrete version, not ${version}.`);
2641
+ }
2642
+ // The plugin's own peers (Capacitor core) must accept the game's
2643
+ // installed copies, as a store plugin's must.
2644
+ assertInstalledPeers(gameRoot, `${name}@${version}`, installed.peerDependencies);
2645
+ return { version, installed: true };
2646
+ }
2647
+ if (gameDependencies[name] !== undefined) {
2648
+ throw new Error(`The game declares ${name} but it is not installed; install game dependencies before `
2649
+ + 'initializing the shell.');
2650
+ }
2651
+ const version = notificationsGameDependencies[name];
2652
+ if (version === undefined) {
2653
+ throw new Error(`No starter version is known for ${name}.`);
2654
+ }
2655
+ return { version, installed: false };
2656
+ };
2657
+ const shellPlugins = notificationsShellPlugins.map((name) => ({
2658
+ packageName: name,
2659
+ ...versionOf(name),
2660
+ }));
2661
+ // The wiring imports @mpgd/adapter-capacitor/notifications: the game's
2662
+ // installed adapter must have it, and the plugins must satisfy its peers.
2663
+ const adapter = readJsonObject(path.join(gameRoot, 'node_modules/@mpgd/adapter-capacitor/package.json'));
2664
+ const adapterExports = adapter.exports;
2665
+ if (typeof adapterExports !== 'object' || adapterExports === null
2666
+ || adapterExports['./notifications'] === undefined) {
2667
+ throw new Error(`--providers notifications needs @mpgd/adapter-capacitor/notifications, which the game's `
2668
+ + `@mpgd/adapter-capacitor ${String(adapter.version)} does not export; upgrade the game's kit `
2669
+ + 'packages (mpgd kit upgrade) first.');
2670
+ }
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.
2675
+ const adapterRoot = path.join(gameRoot, 'node_modules/@mpgd/adapter-capacitor');
2676
+ const declaredTypes = adapterExports['./notifications'];
2677
+ const typesPath = typeof declaredTypes === 'object' && declaredTypes !== null
2678
+ && typeof declaredTypes.types === 'string'
2679
+ ? declaredTypes.types
2680
+ : './dist/notifications.d.ts';
2681
+ 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
2684
+ // `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.");
2690
+ }
2691
+ // The adapter's other peers (Capacitor core) must accept the game's
2692
+ // installed copies too; the plugins get their own message below.
2693
+ assertInstalledPeers(gameRoot, `@mpgd/adapter-capacitor@${String(adapter.version)}`, typeof adapter.peerDependencies === 'object' && adapter.peerDependencies !== null
2694
+ ? Object.fromEntries(Object.entries(adapter.peerDependencies)
2695
+ .filter(([name]) => !notificationsShellPlugins.includes(name)))
2696
+ : undefined);
2697
+ const adapterPeers = typeof adapter.peerDependencies === 'object' && adapter.peerDependencies !== null
2698
+ ? adapter.peerDependencies
2699
+ : {};
2700
+ for (const plugin of shellPlugins) {
2701
+ const range = adapterPeers[plugin.packageName];
2702
+ // A range semver cannot read (`workspace:*`, a tag) is not checked.
2703
+ if (typeof range === 'string' && validRange(range) !== null
2704
+ && !satisfies(plugin.version, range, { includePrerelease: true })) {
2705
+ throw new Error(plugin.installed
2706
+ ? `@mpgd/adapter-capacitor ${String(adapter.version)} needs ${plugin.packageName} ${range}, `
2707
+ + `but the game has ${plugin.version}; align the game's version before rerunning.`
2708
+ : `@mpgd/adapter-capacitor ${String(adapter.version)} needs ${plugin.packageName} ${range}, `
2709
+ + `but this CLI would add ${plugin.version}; add a version in that range to the game `
2710
+ + `(pnpm add ${plugin.packageName}) or upgrade the CLI, then rerun.`);
2711
+ }
2712
+ }
2713
+ // The messaging plugin's web fallback imports `firebase`: the game's own
2714
+ // copy must be in the plugin's peer range. For the plugin the starter
2715
+ // adds, the kit pins the range. An installed plugin names its own, which
2716
+ // `versionOf` already checked against an installed firebase; this covers
2717
+ // the firebase the CLI would add.
2718
+ const messaging = shellPlugins.find((plugin) => plugin.packageName === notificationsMessagingPlugin);
2719
+ const firebase = versionOf('firebase');
2720
+ if (messaging !== undefined) {
2721
+ const installedPeers = messaging.installed
2722
+ ? readJsonObject(path.join(gameRoot, 'node_modules', notificationsMessagingPlugin, 'package.json')).peerDependencies
2723
+ : undefined;
2724
+ const declared = typeof installedPeers === 'object' && installedPeers !== null
2725
+ ? installedPeers['firebase']
2726
+ : undefined;
2727
+ const firebaseRange = messaging.installed ? declared : notificationsMessagingFirebaseRange;
2728
+ if (typeof firebaseRange === 'string' && validRange(firebaseRange) !== null
2729
+ && !satisfies(firebase.version, firebaseRange, { includePrerelease: true })) {
2730
+ throw new Error(firebase.installed
2731
+ ? `@capacitor-firebase/messaging ${messaging.version} needs firebase ${firebaseRange}, but the game `
2732
+ + `has ${firebase.version}; align the game's firebase (pnpm add firebase@"${firebaseRange}") `
2733
+ + 'before rerunning.'
2734
+ : `@capacitor-firebase/messaging ${messaging.version} needs firebase ${firebaseRange}, but this `
2735
+ + `CLI would add ${firebase.version}; add a version in that range to the game `
2736
+ + `(pnpm add firebase@"${firebaseRange}") or upgrade the CLI, then rerun.`);
2737
+ }
2738
+ }
2739
+ const gamePackages = Object.fromEntries(Object.keys(notificationsGameDependencies).map((name) => [name, versionOf(name).version]));
2740
+ const register = 'register createStarterCapacitorNotifications (src/platform/capacitorNotifications.ts) '
2741
+ + 'or createCapacitorNotificationServiceProvider from @mpgd/adapter-capacitor/notifications in its '
2742
+ + 'providers';
2743
+ const files = [];
2744
+ const overlayRoot = input.templateRoot === undefined
2745
+ ? undefined
2746
+ : path.join(path.dirname(input.templateRoot), 'notifications-overlay', 'common');
2747
+ const jsonTitle = JSON.stringify(input.displayName).slice(1, -1);
2748
+ const starterFile = (root, file) => {
2749
+ // A function replacer: `$&` or `$$` in a display name stays literal.
2750
+ const title = file.endsWith('.json') ? jsonTitle : input.displayName;
2751
+ return readTemplateFile(root, file).replaceAll('__GAME_TITLE__', () => title);
2752
+ };
2753
+ // The wired gateway imports these starter modules; a game-owned copy that
2754
+ // differs keeps the gateway as it is, as a differing store module does.
2755
+ const keptModule = overlayRoot === undefined
2756
+ ? undefined
2757
+ : notificationsCapacitorGatewayModules.find((file) => {
2758
+ const existing = readExisting(safeDestination(gameRoot, file));
2759
+ return existing !== undefined
2760
+ && existing.replace(/\r\n/gu, '\n') !== starterFile(overlayRoot, file);
2761
+ });
2762
+ for (const gatewayFile of Object.values(notificationsCapacitorGateways)) {
2763
+ const current = input.plannedGateways.find((file) => file.path === gatewayFile)?.content
2764
+ ?? readExisting(safeDestination(gameRoot, gatewayFile));
2765
+ if (current !== undefined && capacitorGatewayRegistersNotifications(current)) {
2766
+ continue;
2767
+ }
2768
+ if (current === undefined) {
2769
+ warnings.push(`${gatewayFile} is missing; ${register}.`);
2770
+ continue;
2771
+ }
2772
+ const template = input.templateRoot === undefined
2773
+ ? undefined
2774
+ : readTemplateFile(input.templateRoot, gatewayFile);
2775
+ if (template === undefined || current.replace(/\r\n/gu, '\n') !== template) {
2776
+ warnings.push(`${gatewayFile} does not register notifications and is game-owned; ${register}.`);
2777
+ continue;
2778
+ }
2779
+ if (keptModule !== undefined) {
2780
+ warnings.push(`${gatewayFile} was not rewritten because the game-owned ${keptModule} differs from the `
2781
+ + `notifications starter; ${register}.`);
2782
+ continue;
2783
+ }
2784
+ files.push({
2785
+ path: gatewayFile,
2786
+ content: wireCapacitorGatewayNotifications(gatewayFile, template),
2787
+ });
2788
+ }
2789
+ if (files.length > 0 && overlayRoot !== undefined) {
2790
+ // The starter modules and catalog the wired gateways read; the game keeps any it has.
2791
+ for (const file of notificationsCapacitorSupportFiles) {
2792
+ if (readExisting(safeDestination(gameRoot, file)) === undefined) {
2793
+ files.push({ path: file, content: starterFile(overlayRoot, file) });
2794
+ }
2795
+ }
2796
+ warnings.push('The notification settings screen is not added to an existing game; render one from '
2797
+ + 'src/platform/notificationSettings.ts (see docs/NOTIFICATIONS.md) so players can turn '
2798
+ + 'reminders and advertising consent on and off.');
2799
+ }
2800
+ const androidManifest = readExisting(safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`));
2801
+ if (androidManifest !== undefined && !androidManifestRemovesExactAlarm(androidManifest)) {
2802
+ warnings.push(`${shellPath}/android/app/src/main/AndroidManifest.xml keeps SCHEDULE_EXACT_ALARM, which `
2803
+ + '@capacitor/local-notifications adds; reminders are inexact, so add '
2804
+ + `${androidInexactAlarmPermission} to the manifest.`);
2805
+ }
2806
+ if (readExisting(safeDestination(gameRoot, `${shellPath}/android/app/google-services.json`)) === undefined) {
2807
+ warnings.push(`Android push needs ${shellPath}/android/app/google-services.json from the Firebase console `
2808
+ + '(see the mpgd-kit Firebase guide); without it the push half fails closed.');
2809
+ }
2810
+ if (readExisting(safeDestination(gameRoot, `${shellPath}/ios/App/App/GoogleService-Info.plist`))
2811
+ === undefined) {
2812
+ warnings.push(`iOS push needs ${shellPath}/ios/App/App/GoogleService-Info.plist, the Push Notifications `
2813
+ + 'capability, an APNs key uploaded to Firebase, and the AppDelegate forwarding the '
2814
+ + 'messaging plugin documents (see the mpgd-kit Firebase guide).');
2815
+ }
2816
+ return {
2817
+ shellPlugins: shellPlugins.map(({ packageName, version }) => ({ packageName, version })),
2818
+ gameDependencies: gamePackages,
2819
+ files,
2820
+ };
2821
+ }
2822
+ /**
2823
+ * Refuses a package whose declared peers the game's installed copies do not
2824
+ * satisfy (Capacitor core, the kit packages), before anything is written.
2825
+ */
2826
+ function assertInstalledPeers(gameRoot, label, peerDependencies) {
2827
+ if (typeof peerDependencies !== 'object' || peerDependencies === null) {
2828
+ return;
2829
+ }
2830
+ for (const [peer, range] of Object.entries(peerDependencies)) {
2831
+ const peerFile = path.join(gameRoot, 'node_modules', peer, 'package.json');
2832
+ // A range semver cannot read (`workspace:*`, a tag) is not checked.
2833
+ if (typeof range !== 'string' || validRange(range) === null || !existsSync(peerFile)) {
2834
+ continue;
2835
+ }
2836
+ const peerVersion = readJsonObject(peerFile).version;
2837
+ if (typeof peerVersion === 'string' && valid(peerVersion) !== null
2838
+ && !satisfies(peerVersion, range, { includePrerelease: true })) {
2839
+ throw new Error(`${label} needs ${peer} ${range}, but the game has ${peerVersion}; align the game's `
2840
+ + 'versions before rerunning.');
2841
+ }
2842
+ }
2843
+ }
2475
2844
  /** A regular file of the CLI's game template, read without leaving the template root. */
2476
2845
  function readTemplateFile(templateRoot, relative) {
2477
2846
  const root = realpathSync(templateRoot);
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHost
24
24
  import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
25
25
  import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
26
26
  import { defaultMpgdKitPath, initializeMicrosoftStoreStarter, listMicrosoftStoreInitializerTemplateFiles, prepareBaseGameTemplateFile, resolveMicrosoftStoreAdapterDependencyVersion, } from './microsoft-store-starter.js';
27
+ import { applyNotificationsStarter, notificationsProviderId, readGameCreateProviders, } from './notifications-starter.js';
27
28
  import { applyKitUpgrade, planKitUpgrade } from './kit-upgrade.js';
28
29
  import { applyToolingMigration, planToolingMigration } from './tooling-migration.js';
29
30
  import { runMicrosoftStoreSubmissionPreflight } from './microsoft-store-submission.js';
@@ -58,6 +59,7 @@ const packageRoot = resolvePackageRoot(sourceDir);
58
59
  const detectedKitRoot = resolveDefaultKitRoot();
59
60
  const gameTemplateDir = path.resolve(packageRoot, 'templates/phaser-game');
60
61
  const liteTemplateOverlayDir = path.resolve(packageRoot, 'templates/lite-overlay');
62
+ const notificationsTemplateDir = path.resolve(packageRoot, 'templates/notifications-overlay');
61
63
  const supportedGameTemplates = ['phaser', 'lite'];
62
64
  /** Lite renderers implemented today. dom/svg are reserved extension points in @mpgd/lite-shell. */
63
65
  const supportedLiteRenderers = ['canvas'];
@@ -575,6 +577,10 @@ const gameCommand = defineI18n({
575
577
  en: 'Lite template renderer. Only canvas is implemented today.',
576
578
  ko: 'lite 템플릿 렌더러. 현재는 canvas만 지원합니다.',
577
579
  },
580
+ providers: {
581
+ en: 'Comma-separated game providers. notifications adds the notifications v2 starter: the topic catalog, the backend settings, the Android, iOS, Apps in Toss, and Devvit wiring, and a notification settings screen.',
582
+ ko: '쉼표로 구분한 게임 provider. notifications는 알림 v2 스타터(토픽 카탈로그, 백엔드 설정, Android·iOS·앱인토스·Devvit 연결, 알림 설정 화면)를 추가합니다.',
583
+ },
578
584
  }),
579
585
  args: {
580
586
  template: {
@@ -627,6 +633,11 @@ const gameCommand = defineI18n({
627
633
  required: false,
628
634
  description: 'Include the Microsoft Store PWA target and release workflow.',
629
635
  },
636
+ providers: {
637
+ type: 'string',
638
+ required: false,
639
+ description: 'Comma-separated game providers. notifications adds the notifications v2 starter.',
640
+ },
630
641
  },
631
642
  run: (ctx) => {
632
643
  const positionals = readLocalPositionals(ctx.positionals, ['game', 'create']);
@@ -649,6 +660,7 @@ const gameCommand = defineI18n({
649
660
  ...(kitPath === undefined ? {} : { kitPath }),
650
661
  dryRun: ctx.values['dry-run'] === true,
651
662
  microsoftStore: ctx.values['microsoft-store'] === true,
663
+ providers: readGameCreateProviders(readOptionalString(ctx.values.providers)),
652
664
  });
653
665
  },
654
666
  }),
@@ -1597,8 +1609,8 @@ const targetCommand = defineI18n({
1597
1609
  ko: '프로덕션 HTTPS Game Services 기본 URL.',
1598
1610
  },
1599
1611
  providers: {
1600
- en: 'Comma-separated provider IDs. play-billing (Android) and storekit (iOS) install the kit store plugin into the shell and the game, register it with cap sync, and wire an unwired template gateway; other IDs are recorded only.',
1601
- ko: '쉼표로 구분한 provider ID. play-billing(Android)과 storekit(iOS)은 키트 스토어 플러그인을 셸과 게임에 설치하고 cap sync로 등록하며, 연결되지 않은 템플릿 게이트웨이를 연결합니다. 다른 ID는 기록만 합니다.',
1612
+ en: 'Comma-separated provider IDs. play-billing (Android) and storekit (iOS) install the kit store plugin into the shell and the game, register it with cap sync, and wire an unwired template gateway; notifications installs the Firebase messaging and local notifications plugins for Android and iOS and wires a template gateway; other IDs are recorded only.',
1613
+ ko: '쉼표로 구분한 provider ID. play-billing(Android)과 storekit(iOS)은 키트 스토어 플러그인을 셸과 게임에 설치하고 cap sync로 등록하며, 연결되지 않은 템플릿 게이트웨이를 연결합니다. notifications는 Android·iOS용 Firebase 메시징과 로컬 알림 플러그인을 설치하고 템플릿 게이트웨이를 연결합니다. 다른 ID는 기록만 합니다.',
1602
1614
  },
1603
1615
  }),
1604
1616
  args: {
@@ -1646,7 +1658,7 @@ const targetCommand = defineI18n({
1646
1658
  providers: {
1647
1659
  type: 'string',
1648
1660
  required: false,
1649
- description: 'Comma-separated provider IDs. play-billing (Android) and storekit (iOS) install the kit store plugin into the shell and the game, register it with cap sync, and wire an unwired template gateway; other IDs are recorded only.',
1661
+ description: 'Comma-separated provider IDs. play-billing (Android) and storekit (iOS) install the kit store plugin into the shell and the game, register it with cap sync, and wire an unwired template gateway; notifications installs the Firebase messaging and local notifications plugins for Android and iOS and wires a template gateway; other IDs are recorded only.',
1650
1662
  },
1651
1663
  },
1652
1664
  run: (ctx) => {
@@ -1693,6 +1705,11 @@ const targetCommand = defineI18n({
1693
1705
  + `${plugin.versionSource === 'kit' ? ' and the game' : ''}`
1694
1706
  + `${dryRun ? '; cap add or cap sync registers it' : ''}.`);
1695
1707
  }
1708
+ if (plan.notificationPlugins.length > 0) {
1709
+ console.info(`${dryRun ? 'Would install' : 'Installed'} notifications plugins `
1710
+ + plan.notificationPlugins.map((plugin) => `${plugin.packageName}@${plugin.version}`).join(', ')
1711
+ + ` into the shell for android and ios${dryRun ? '; cap add or cap sync registers them' : ''}.`);
1712
+ }
1696
1713
  for (const warning of plan.warnings) {
1697
1714
  console.warn(`Warning: ${warning}`);
1698
1715
  }
@@ -2943,9 +2960,17 @@ function createGameApp(input) {
2943
2960
  microsoftStore: input.microsoftStore,
2944
2961
  });
2945
2962
  const baseTemplateFiles = collectTemplateFiles(gameTemplateDir);
2946
- const selectedTemplateFiles = input.template === 'lite'
2963
+ const templateFiles = input.template === 'lite'
2947
2964
  ? applyLiteTemplateOverlay(baseTemplateFiles, collectTemplateFiles(liteTemplateOverlayDir))
2948
2965
  : baseTemplateFiles;
2966
+ const selectedTemplateFiles = input.providers?.includes(notificationsProviderId) === true
2967
+ ? applyNotificationsStarter(templateFiles, {
2968
+ template: input.template === 'lite' ? 'lite' : 'phaser',
2969
+ common: collectTemplateFiles(path.join(notificationsTemplateDir, 'common')),
2970
+ phaser: collectTemplateFiles(path.join(notificationsTemplateDir, 'phaser')),
2971
+ lite: collectTemplateFiles(path.join(notificationsTemplateDir, 'lite')),
2972
+ })
2973
+ : templateFiles;
2949
2974
  const files = selectedTemplateFiles.flatMap((file) => {
2950
2975
  const prepared = prepareBaseGameTemplateFile({
2951
2976
  relativePath: file.relativePath,
@@ -3371,8 +3396,14 @@ function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
3371
3396
  ?? readSourceGitSha(gameRoot);
3372
3397
  const appVersion = readOptionalString(baseEnv.APP_VERSION)
3373
3398
  ?? readGamePackageVersion(gameRoot);
3399
+ const notificationTopicsFile = readConfiguredPath(baseEnv.MPGD_NOTIFICATION_TOPICS_FILE);
3374
3400
  return {
3375
3401
  ...monetizationFiles,
3402
+ // The Apps in Toss wrapper builds in its own directory; a relative path
3403
+ // names a file of the game, as `pnpm validate:notifications` reads it.
3404
+ ...(notificationTopicsFile === undefined
3405
+ ? {}
3406
+ : { MPGD_NOTIFICATION_TOPICS_FILE: path.resolve(gameRoot, notificationTopicsFile) }),
3376
3407
  MPGD_SOURCE_GIT_SHA: sourceGitSha,
3377
3408
  ...(appVersion === undefined ? {} : { APP_VERSION: appVersion }),
3378
3409
  };