@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
@@ -0,0 +1,222 @@
1
+ {
2
+ "version": "1",
3
+ "defaultLocale": "ko",
4
+ "notices": {
5
+ "topic": "consent-notice",
6
+ "copy": {
7
+ "ko": {
8
+ "consent-result.advertising.granted": {
9
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 결과",
10
+ "body": "{{consentDate}}에 하신 광고성 정보 수신동의가 처리됐어요. 알림 설정에서 언제든 철회할 수 있어요."
11
+ },
12
+ "consent-result.advertising.refused": {
13
+ "title": "[__GAME_TITLE__] 광고성 정보 수신거부 결과",
14
+ "body": "{{consentDate}}에 하신 광고성 정보 수신거부가 처리됐어요."
15
+ },
16
+ "consent-result.advertising.withdrawn": {
17
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 철회 결과",
18
+ "body": "{{consentDate}}에 하신 광고성 정보 수신동의 철회가 처리됐어요."
19
+ },
20
+ "consent-result.night.granted": {
21
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 결과",
22
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신동의가 처리됐어요. 알림 설정에서 언제든 철회할 수 있어요."
23
+ },
24
+ "consent-result.night.refused": {
25
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신거부 결과",
26
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신거부가 처리됐어요."
27
+ },
28
+ "consent-result.night.withdrawn": {
29
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 철회 결과",
30
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신동의 철회가 처리됐어요."
31
+ },
32
+ "reconfirmation.advertising": {
33
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 확인",
34
+ "body": "{{consentDate}}에 광고성 정보 수신에 동의하셨어요. 동의를 유지하려면 따로 하실 일은 없고, 철회하려면 게임의 알림 설정에서 광고성 정보 수신을 끄면 돼요."
35
+ },
36
+ "reconfirmation.night": {
37
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 확인",
38
+ "body": "{{consentDate}}에 야간(21시~다음 날 8시) 광고성 정보 수신에 동의하셨어요. 동의를 유지하려면 따로 하실 일은 없고, 철회하려면 게임의 알림 설정에서 야간 수신을 끄면 돼요."
39
+ }
40
+ },
41
+ "en": {
42
+ "consent-result.advertising.granted": {
43
+ "title": "[__GAME_TITLE__] Promotional notifications: consent processed",
44
+ "body": "Your consent to promotional notifications on {{consentDate}} has been processed. You can withdraw it any time in the game's notification settings."
45
+ },
46
+ "consent-result.advertising.refused": {
47
+ "title": "[__GAME_TITLE__] Promotional notifications: refusal processed",
48
+ "body": "Your refusal of promotional notifications on {{consentDate}} has been processed."
49
+ },
50
+ "consent-result.advertising.withdrawn": {
51
+ "title": "[__GAME_TITLE__] Promotional notifications: withdrawal processed",
52
+ "body": "Your withdrawal of consent to promotional notifications on {{consentDate}} has been processed."
53
+ },
54
+ "consent-result.night.granted": {
55
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: consent processed",
56
+ "body": "Your consent to promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed. You can withdraw it any time in the game's notification settings."
57
+ },
58
+ "consent-result.night.refused": {
59
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: refusal processed",
60
+ "body": "Your refusal of promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed."
61
+ },
62
+ "consent-result.night.withdrawn": {
63
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: withdrawal processed",
64
+ "body": "Your withdrawal of consent to promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed."
65
+ },
66
+ "reconfirmation.advertising": {
67
+ "title": "[__GAME_TITLE__] Promotional notifications: your consent",
68
+ "body": "You agreed to promotional notifications on {{consentDate}}. To keep this consent you need do nothing; to withdraw it, turn promotional notifications off in the game's notification settings."
69
+ },
70
+ "reconfirmation.night": {
71
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: your consent",
72
+ "body": "You agreed to promotional notifications between 21:00 and 08:00 on {{consentDate}}. To keep this consent you need do nothing; to withdraw it, turn night-time promotional notifications off in the game's notification settings."
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "topics": [
78
+ {
79
+ "id": "daily-ready",
80
+ "purpose": "requested",
81
+ "channels": [
82
+ "local",
83
+ "remote"
84
+ ],
85
+ "route": "/?entry=continue",
86
+ "copy": {
87
+ "ko": {
88
+ "title": "오늘의 플레이",
89
+ "body": "오늘의 판이 준비됐어요."
90
+ },
91
+ "en": {
92
+ "title": "Today's run",
93
+ "body": "Today's run is ready."
94
+ }
95
+ },
96
+ "frequency": {
97
+ "maxPerDay": 1
98
+ },
99
+ "quietHours": {
100
+ "start": "21:00",
101
+ "end": "08:00"
102
+ },
103
+ "targets": {
104
+ "android": {
105
+ "channelId": "mpgd-requested"
106
+ },
107
+ "ios": {},
108
+ "reddit": {
109
+ "title": "Today's run is ready",
110
+ "body": "Today's run is ready when you are."
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "id": "streak-at-risk",
116
+ "purpose": "requested",
117
+ "channels": [
118
+ "local",
119
+ "remote"
120
+ ],
121
+ "route": "/?entry=continue",
122
+ "copy": {
123
+ "ko": {
124
+ "title": "연속 플레이 기록",
125
+ "body": "오늘 플레이하면 연속 기록이 이어져요."
126
+ },
127
+ "en": {
128
+ "title": "Your streak",
129
+ "body": "Play today to keep your streak going."
130
+ }
131
+ },
132
+ "frequency": {
133
+ "maxPerDay": 1
134
+ },
135
+ "quietHours": {
136
+ "start": "21:00",
137
+ "end": "08:00"
138
+ },
139
+ "targets": {
140
+ "android": {
141
+ "channelId": "mpgd-requested"
142
+ },
143
+ "ios": {},
144
+ "reddit": {
145
+ "title": "Your streak",
146
+ "body": "Play today to keep your streak going."
147
+ }
148
+ }
149
+ },
150
+ {
151
+ "id": "friend-challenge",
152
+ "purpose": "service",
153
+ "channels": [
154
+ "remote"
155
+ ],
156
+ "route": "/?entry=challenge",
157
+ "copy": {
158
+ "ko": {
159
+ "title": "친구의 도전",
160
+ "body": "{{friend}} 님이 도전장을 보냈어요."
161
+ },
162
+ "en": {
163
+ "title": "Friend challenge",
164
+ "body": "{{friend}} sent you a challenge."
165
+ }
166
+ },
167
+ "quietHours": {
168
+ "start": "22:00",
169
+ "end": "08:00"
170
+ },
171
+ "targets": {
172
+ "android": {
173
+ "channelId": "mpgd-service"
174
+ },
175
+ "ios": {},
176
+ "reddit": {
177
+ "title": "Friend challenge",
178
+ "body": "A friend sent you a challenge."
179
+ }
180
+ }
181
+ },
182
+ {
183
+ "id": "events",
184
+ "purpose": "advertising",
185
+ "channels": [
186
+ "remote"
187
+ ],
188
+ "route": "/?entry=events",
189
+ "optOutRoute": "/?entry=notification-settings",
190
+ "copy": {
191
+ "ko": {
192
+ "title": "이번 주 이벤트",
193
+ "body": "새 이벤트가 시작됐어요."
194
+ },
195
+ "en": {
196
+ "title": "This week's event",
197
+ "body": "A new event has started."
198
+ }
199
+ },
200
+ "frequency": {
201
+ "maxPerDay": 1
202
+ },
203
+ "targets": {
204
+ "ios": {}
205
+ }
206
+ },
207
+ {
208
+ "id": "consent-notice",
209
+ "purpose": "service",
210
+ "channels": [
211
+ "remote"
212
+ ],
213
+ "route": "/?entry=notification-settings",
214
+ "targets": {
215
+ "android": {
216
+ "channelId": "mpgd-service"
217
+ },
218
+ "ios": {}
219
+ }
220
+ }
221
+ ]
222
+ }
@@ -0,0 +1,59 @@
1
+ import type { CapacitorServiceProvider } from '@mpgd/adapter-capacitor';
2
+ import {
3
+ createCapacitorNotificationServiceProvider,
4
+ loadCapacitorNotificationSdks,
5
+ openCapacitorNotificationSettings,
6
+ } from '@mpgd/adapter-capacitor/notifications';
7
+
8
+ import { starterNotificationTopics } from './notifications';
9
+
10
+ /**
11
+ * The Android and iOS notifications v2 provider over
12
+ * `@capacitor-firebase/messaging` (remote, FCM for both platforms) and
13
+ * `@capacitor/local-notifications` (local reminders, the permission read,
14
+ * and the advertising opt-out action). `mpgd target init capacitor
15
+ * --providers notifications` installs both into the native shell. A shell
16
+ * without them still runs: the provider fails closed for the missing half,
17
+ * and without both it is `unsupported`.
18
+ *
19
+ * It is created once, early, because the provider registers its native
20
+ * listeners when it is created, which is how a cold-start open reaches it.
21
+ * Never install `@capacitor/push-notifications` next to it: on iOS both
22
+ * claim Capacitor's one push handler.
23
+ */
24
+ export async function createStarterCapacitorNotifications(
25
+ platform: 'android' | 'ios',
26
+ configTarget: string,
27
+ ): Promise<readonly CapacitorServiceProvider[]> {
28
+ const topics = starterNotificationTopics(configTarget);
29
+ if (topics.length === 0) {
30
+ return [];
31
+ }
32
+ const sdks = await loadCapacitorNotificationSdks();
33
+ const korean = (globalThis.navigator?.language ?? '').toLowerCase().startsWith('ko');
34
+ return [
35
+ createCapacitorNotificationServiceProvider({
36
+ platform,
37
+ topics,
38
+ ...sdks,
39
+ // Android channel names the player sees in system settings, in the
40
+ // device language. The provider creates a channel only for the topics
41
+ // mapped to Android; `mpgd-advertising` is named for the day an
42
+ // advertising topic is mapped there, in a channel of its own the
43
+ // player can turn off.
44
+ channels: korean
45
+ ? {
46
+ 'mpgd-service': { name: '알림' },
47
+ 'mpgd-requested': { name: '리마인더' },
48
+ 'mpgd-advertising': { name: '광고성 정보' },
49
+ }
50
+ : {
51
+ 'mpgd-service': { name: 'Notifications' },
52
+ 'mpgd-requested': { name: 'Reminders' },
53
+ 'mpgd-advertising': { name: 'Promotions' },
54
+ },
55
+ advertisingOptOutTitle: korean ? '수신거부' : 'Unsubscribe',
56
+ openSettings: openCapacitorNotificationSettings,
57
+ }),
58
+ ];
59
+ }
@@ -0,0 +1,60 @@
1
+ import { isRootRelativeNotificationRoute } from '@mpgd/catalog';
2
+ import type { NotificationEvent } from '@mpgd/platform/notifications';
3
+
4
+ /** The route the advertising opt-out action and the consent notices open. */
5
+ export const starterNotificationSettingsRoute = '/?entry=notification-settings';
6
+
7
+ export interface NotificationOpenRoutes {
8
+ /** An open whose route is the settings screen. */
9
+ readonly settings: () => void;
10
+ /** Every other open, as the provider reported it, with `route` only when it is safe. */
11
+ readonly other?: (open: Omit<Extract<NotificationEvent, { readonly type: 'opened' }>, 'route'> & {
12
+ readonly route?: string;
13
+ }) => void;
14
+ }
15
+
16
+ /**
17
+ * The game's one listener for notification opens. It is the runtime's (or
18
+ * the provider's) first subscriber, which is the one that receives a
19
+ * cold-start open (RFC "Buffer before the first listener"), so route every
20
+ * topic's opens here rather than in a later subscriber. The starter routes
21
+ * the settings screen: the advertising opt-out action and the consent
22
+ * notices carry `starterNotificationSettingsRoute`, and the RFC requires the
23
+ * opt-out to open that screen directly. `other` receives every other open,
24
+ * with a route only when `isRootRelativeNotificationRoute` accepts it. The
25
+ * data an open carries is untrusted and never grants anything. Returns the
26
+ * unsubscribe.
27
+ */
28
+ export function routeNotificationOpens(
29
+ source: { subscribe(listener: (event: NotificationEvent) => void): () => void },
30
+ routes: NotificationOpenRoutes,
31
+ ): () => void {
32
+ return source.subscribe((event) => {
33
+ if (event.type !== 'opened') {
34
+ return;
35
+ }
36
+ const route = event.route !== undefined && isRootRelativeNotificationRoute(event.route)
37
+ ? event.route
38
+ : undefined;
39
+ if (route !== undefined && isSettingsRoute(route)) {
40
+ routes.settings();
41
+ return;
42
+ }
43
+ const { route: _unsafe, ...rest } = event;
44
+ routes.other?.({ ...rest, ...(route === undefined ? {} : { route }) });
45
+ });
46
+ }
47
+
48
+ const settingsEntry = new URL(starterNotificationSettingsRoute, 'https://game.invalid')
49
+ .searchParams.get('entry');
50
+
51
+ /**
52
+ * Whether a route opens the settings screen: the same `entry` as
53
+ * `starterNotificationSettingsRoute`. Only routes
54
+ * `isRootRelativeNotificationRoute` accepted reach it, which parse.
55
+ */
56
+ function isSettingsRoute(route: string): boolean {
57
+ // A settings route without an `entry` would match every route: fail closed.
58
+ return settingsEntry !== null
59
+ && new URL(route, 'https://game.invalid').searchParams.get('entry') === settingsEntry;
60
+ }