@mpgd/cli 0.52.0 → 0.54.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.
- package/dist/capacitor-shell-starter.d.ts +22 -0
- package/dist/capacitor-shell-starter.js +395 -18
- package/dist/deploy-planning.js +2 -2
- package/dist/index.js +122 -34
- package/dist/kit-upgrade.js +2 -2
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +433 -2
- package/dist/notifications-starter.d.ts +75 -0
- package/dist/notifications-starter.js +522 -0
- package/dist/offline-playtest.js +1 -1
- package/dist/tooling-migration.js +1 -1
- package/package.json +16 -16
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +104 -0
- package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
- package/templates/notifications-overlay/common/notifications/topics.json +222 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
- package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
- package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +163 -0
- package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
- package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +301 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
- package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
- package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
- package/templates/phaser-game/src/platform/gameServices.ts +3 -0
- package/templates/phaser-game/src/platform/serverSession.ts +66 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Notifications in __GAME_TITLE__
|
|
2
|
+
|
|
3
|
+
This game was created with `mpgd game create --providers notifications`. The
|
|
4
|
+
mpgd-kit notifications guide (`docs/NOTIFICATIONS.md` in mpgd-kit) explains
|
|
5
|
+
the whole flow; this page lists what the starter put in this game and what is
|
|
6
|
+
left for you.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it is |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `notifications/topics.json` | The topic catalog: each topic's purpose, channels, routes, copy, and per-target mapping. The one source for every target and the backend. |
|
|
13
|
+
| `notifications/deployment.json` | The backend settings: `advertisingRules` (`kr`), the advertising sender, and the zone. |
|
|
14
|
+
| `src/platform/notifications.ts` | Reads the catalog for a target, and the place to return your notifications runtime. |
|
|
15
|
+
| `src/platform/capacitorNotifications.ts` | The Android and iOS provider (FCM and local reminders), registered by the Capacitor gateways. |
|
|
16
|
+
| `src/platform/notificationSettings.ts` | The settings screen's logic: permission, reminders, advertising and night consent, notices. |
|
|
17
|
+
| `src/platform/notificationOpens.ts` | The game's one listener for notification opens: the settings route, and an `other` route for your topics. |
|
|
18
|
+
| `src/scenes/NotificationSettingsScene.ts` or `src/lite/notificationSettingsPanel.ts` | The settings screen the game renders. |
|
|
19
|
+
| `tools/validate-notifications.ts` | `pnpm validate:notifications`. |
|
|
20
|
+
|
|
21
|
+
## Before you release
|
|
22
|
+
|
|
23
|
+
1. Replace `REPLACE-WITH-SENDER-PHONE-OR-ADDRESS` in
|
|
24
|
+
`notifications/deployment.json` with the sender's phone number or
|
|
25
|
+
address. Every advertising push carries it under the Korean rules, and
|
|
26
|
+
`pnpm validate:notifications` fails until you do. The game backend
|
|
27
|
+
refuses the placeholder as well: a Worker given this file as is does not
|
|
28
|
+
start its notifications.
|
|
29
|
+
2. Keep the sender in `notices.copy` (`[__GAME_TITLE__]` in every variant) the
|
|
30
|
+
same as `advertisingSender.name` in `notifications/deployment.json`: a
|
|
31
|
+
consent notice must name the sender.
|
|
32
|
+
3. Run `pnpm validate:notifications` after every catalog change.
|
|
33
|
+
4. Decide each topic's purpose with your own legal review. The starter's
|
|
34
|
+
`daily-ready` and `streak-at-risk` are `requested`: off until the player
|
|
35
|
+
turns them on, and their copy states only the player's own progress. If
|
|
36
|
+
you add a reward, a benefit, or a promotion to them, or send them to
|
|
37
|
+
players who did not turn them on, declare them `advertising` instead.
|
|
38
|
+
5. Android and iOS: set up Firebase (see the mpgd-kit Firebase guide,
|
|
39
|
+
`docs/FIREBASE_NOTIFICATIONS.md`), then run
|
|
40
|
+
`mpgd target init capacitor --providers notifications` to install the two
|
|
41
|
+
plugins into the game-owned shell. Never install
|
|
42
|
+
`@capacitor/push-notifications` next to them.
|
|
43
|
+
6. Advertising on Android: the sample `events` topic is mapped to iOS only.
|
|
44
|
+
An Android FCM notification cannot carry the opt-out action that the
|
|
45
|
+
Korean rules require on every advertising push (a settings path in the
|
|
46
|
+
text does not replace it), and the kit does not render Android
|
|
47
|
+
advertising natively yet. Do not add an `android` mapping to an
|
|
48
|
+
advertising topic until it does.
|
|
49
|
+
7. Apps in Toss: register each functional-message template in the console and
|
|
50
|
+
add its code to the topic's `targets.ait` mapping
|
|
51
|
+
(`{ "templateSetCode": "...", "agreement": "required" }`). Apps in Toss
|
|
52
|
+
cannot deliver advertising topics.
|
|
53
|
+
8. Reddit: apply for Devvit notifications; keep `integrations.notifications`
|
|
54
|
+
at `approval-required` until Reddit approves the app and its copy.
|
|
55
|
+
|
|
56
|
+
## The notifications runtime
|
|
57
|
+
|
|
58
|
+
Consent records (reminders, 광고성 정보 수신동의, night consent), endpoint
|
|
59
|
+
registration, server reminders, and consent notices go through the game
|
|
60
|
+
backend. `createStarterNotificationBackend` in
|
|
61
|
+
`src/platform/notifications.ts` builds the notifications runtime
|
|
62
|
+
(`@mpgd/platform/notifications-runtime`) over the game-services Worker's
|
|
63
|
+
notification routes (`@mpgd/game-services/notifications/client`) when the
|
|
64
|
+
game has both:
|
|
65
|
+
|
|
66
|
+
- a game-services URL, `VITE_MPGD_GAME_SERVICES_URL`, the one the commerce
|
|
67
|
+
client uses (a public https URL in production); and
|
|
68
|
+
- a server-backed session, which the game sets with
|
|
69
|
+
`setStarterServerSession` in `src/platform/serverSession.ts` at the top of
|
|
70
|
+
`src/main.ts`, before `bootstrap()`: the signed-in `{ playerId, sessionId }`
|
|
71
|
+
(`undefined` until the player signs in), a sign-in subscription, and the
|
|
72
|
+
session's `Authorization` header. The commerce client then sends the same
|
|
73
|
+
header. `sessionId` must be the stable session-family id: the `sessionId` your
|
|
74
|
+
identity service answers to the Worker's `authenticateNotificationSession`
|
|
75
|
+
for this session's header, the same across token refreshes (see the kit's
|
|
76
|
+
`docs/CLOUDFLARE_WORKER_DEPLOY.md`, "Sessions"). Do not pass a per-token
|
|
77
|
+
or rotating id: a `GuestSessionCoordinator` view's `sessionId` may rotate
|
|
78
|
+
on refresh, so it is not that id.
|
|
79
|
+
|
|
80
|
+
Without either, it returns `undefined` and the settings screen asks only
|
|
81
|
+
for the OS or host permission (and withdraws a Devvit opt-in the game
|
|
82
|
+
rendered). With them, the runtime registers the device for the signed-in
|
|
83
|
+
player, keeps its registration state in the platform storage, and
|
|
84
|
+
`listConsent` reads the player's consent records (the Worker's
|
|
85
|
+
`GET /consent`). The runtime also loads the Worker's registry entry for
|
|
86
|
+
its target (`GET /providers`) and turns notifications into a configuration
|
|
87
|
+
error when the provider disagrees with it; a Worker it cannot reach is
|
|
88
|
+
logged and the runtime runs without that check.
|
|
89
|
+
|
|
90
|
+
Reddit never gets this backend, even with a URL and a session: a Reddit
|
|
91
|
+
game's notifications backend is its Devvit server, which keeps the opt-in,
|
|
92
|
+
the records, and the sends, and the Worker serves no `reddit` target. The
|
|
93
|
+
Worker serves the Android, iOS, and Apps in Toss providers.
|
|
94
|
+
|
|
95
|
+
The settings screen shows each
|
|
96
|
+
switch from the latest record, read every time it opens and after every
|
|
97
|
+
change, so a choice made on another device shows. A tap reads the records
|
|
98
|
+
again and writes only what changes. It keeps nothing on the device, and
|
|
99
|
+
shows no switch when the records cannot be read.
|
|
100
|
+
|
|
101
|
+
Night consent adds to 광고성 정보 수신동의: the screen offers it only while
|
|
102
|
+
advertising consent is granted, and turning advertising consent off also
|
|
103
|
+
withdraws a granted night consent in the same call (two records, two result
|
|
104
|
+
notices).
|
|
@@ -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,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPublicGameServicesUrl,
|
|
3
|
+
resolveGameServicesAuthorityMode,
|
|
4
|
+
type GameServicesHeaderResolver,
|
|
5
|
+
} from '@mpgd/game-services';
|
|
6
|
+
import {
|
|
7
|
+
createGameServicesNotificationsClient,
|
|
8
|
+
type GameServicesNotificationIdentity,
|
|
9
|
+
type GameServicesNotificationsFetch,
|
|
10
|
+
} from '@mpgd/game-services/notifications/client';
|
|
11
|
+
import type {
|
|
12
|
+
NotificationConsentRecord,
|
|
13
|
+
NotificationRuntime,
|
|
14
|
+
NotificationTopicDeclaration,
|
|
15
|
+
} from '@mpgd/platform/notifications';
|
|
16
|
+
import {
|
|
17
|
+
createNotificationRuntime,
|
|
18
|
+
createNotificationRuntimeStorage,
|
|
19
|
+
} from '@mpgd/platform/notifications-runtime';
|
|
20
|
+
import type { TargetConfiguredGateway } from '@mpgd/target-config';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The game backend's notifications surface the starter uses: the runtime
|
|
24
|
+
* (notifications RFC "Runtime"), which composes the target's provider with
|
|
25
|
+
* the backend that keeps consent records, endpoint registrations, server
|
|
26
|
+
* reminders, and notices; and the signed-in player's consent records (the
|
|
27
|
+
* game-services Worker's `GET /consent`), which the settings screen shows.
|
|
28
|
+
*/
|
|
29
|
+
export interface StarterNotificationBackend {
|
|
30
|
+
readonly runtime: NotificationRuntime;
|
|
31
|
+
listConsent(): Promise<readonly NotificationConsentRecord[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The server-backed session the Worker client needs (see `serverSession.ts`). */
|
|
35
|
+
export interface StarterWorkerSession extends GameServicesNotificationIdentity {
|
|
36
|
+
readonly getHeaders: GameServicesHeaderResolver;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StarterWorkerNotificationInput {
|
|
40
|
+
/** `VITE_MPGD_GAME_SERVICES_URL`. */
|
|
41
|
+
readonly baseUrl: string | undefined;
|
|
42
|
+
/** The Vite mode (`import.meta.env.MODE`), which decides the production URL rule. */
|
|
43
|
+
readonly mode: string;
|
|
44
|
+
readonly session: StarterWorkerSession | undefined;
|
|
45
|
+
/** This target's topics, each with its mapping. */
|
|
46
|
+
readonly topics: readonly NotificationTopicDeclaration[];
|
|
47
|
+
readonly defaultLocale: string;
|
|
48
|
+
/** Logs a message, once per gateway and message. */
|
|
49
|
+
readonly log: (message: string, error?: unknown) => void;
|
|
50
|
+
/** Defaults to `globalThis.fetch`. */
|
|
51
|
+
readonly fetch?: GameServicesNotificationsFetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Config targets whose notifications backend is not the game-services
|
|
56
|
+
* Worker. A Reddit game's backend is its Devvit server: the opt-in, the
|
|
57
|
+
* consent records, and the sends live there, and the Worker serves no
|
|
58
|
+
* `reddit` target. A runtime over the Worker would send the game's session
|
|
59
|
+
* header to a backend that cannot use it, so Reddit keeps no backend here,
|
|
60
|
+
* as before R1, until the Devvit server has a runtime client of its own.
|
|
61
|
+
*/
|
|
62
|
+
const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The notifications runtime over the game-services Worker's notification
|
|
66
|
+
* routes (`@mpgd/game-services/notifications/client`), when the target's
|
|
67
|
+
* backend is the Worker, the game has a game-services URL and a
|
|
68
|
+
* server-backed session, and the target exposes a notifications provider.
|
|
69
|
+
* Otherwise `undefined`.
|
|
70
|
+
*
|
|
71
|
+
* The runtime keeps its registration state in the platform storage, starts
|
|
72
|
+
* here, and compares its provider with the Worker's registry entry for this
|
|
73
|
+
* target (`GET /providers`), loaded in the background: a Worker it cannot
|
|
74
|
+
* reach is logged once and the runtime runs without the comparison.
|
|
75
|
+
*/
|
|
76
|
+
export function createStarterWorkerNotificationBackend(
|
|
77
|
+
platform: TargetConfiguredGateway,
|
|
78
|
+
input: StarterWorkerNotificationInput,
|
|
79
|
+
): StarterNotificationBackend | undefined {
|
|
80
|
+
// By platform, and by config target name for a gateway that reports another platform.
|
|
81
|
+
if (platform.target === 'reddit' || targetsWithoutWorkerBackend.has(platform.configTarget)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const baseUrl = input.baseUrl?.trim();
|
|
85
|
+
const provider = platform.notificationsV2;
|
|
86
|
+
if (baseUrl === undefined || baseUrl === '' || provider === undefined) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const { session } = input;
|
|
90
|
+
if (session === undefined) {
|
|
91
|
+
input.log(
|
|
92
|
+
'[notifications] VITE_MPGD_GAME_SERVICES_URL is set but no server session is: call setStarterServerSession '
|
|
93
|
+
+ '(src/platform/serverSession.ts) before the game starts. The settings screen shows no switch until then.',
|
|
94
|
+
);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
if (!isAllowedGameServicesUrl(baseUrl, input.mode)) {
|
|
99
|
+
// The session's credentials never travel in clear text or to a private host in production.
|
|
100
|
+
input.log(
|
|
101
|
+
'[notifications] VITE_MPGD_GAME_SERVICES_URL must be an absolute http(s) URL, and a public https URL '
|
|
102
|
+
+ 'without credentials in production; notifications stay off.',
|
|
103
|
+
);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const client = createGameServicesNotificationsClient({
|
|
107
|
+
baseUrl,
|
|
108
|
+
identity: session,
|
|
109
|
+
getHeaders: () => session.getHeaders(),
|
|
110
|
+
// The catalog target whose mapping applies to this device (android or ios share FCM).
|
|
111
|
+
target: platform.configTarget,
|
|
112
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
113
|
+
});
|
|
114
|
+
const runtime = createNotificationRuntime({
|
|
115
|
+
provider,
|
|
116
|
+
backend: client,
|
|
117
|
+
catalog: input.topics,
|
|
118
|
+
defaultLocale: input.defaultLocale,
|
|
119
|
+
storage: createNotificationRuntimeStorage({ storage: platform.storage }),
|
|
120
|
+
// This target's entry in the Worker's registry, or none when the Worker does not serve it.
|
|
121
|
+
async loadRegistryEntry() {
|
|
122
|
+
const { providers, targets } = await client.getProviders();
|
|
123
|
+
const id = targets[platform.configTarget];
|
|
124
|
+
return id === undefined ? undefined : providers.find((entry) => entry.provider === id);
|
|
125
|
+
},
|
|
126
|
+
onError(error) {
|
|
127
|
+
console.error('[notifications]', error);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
// It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
|
|
131
|
+
runtime.start().catch((error: unknown) => {
|
|
132
|
+
console.error('[notifications] the runtime could not start.', error);
|
|
133
|
+
});
|
|
134
|
+
return { runtime, listConsent: () => client.listConsent() };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// A configuration the client refuses turns notifications off, never the game.
|
|
137
|
+
input.log('[notifications] the notifications backend could not be created.', error);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether the game-services URL may carry the session's credentials: any
|
|
144
|
+
* absolute http(s) URL outside production builds, and in production the
|
|
145
|
+
* public https URL the commerce client requires (`isPublicGameServicesUrl`).
|
|
146
|
+
*/
|
|
147
|
+
function isAllowedGameServicesUrl(baseUrl: string, mode: string): boolean {
|
|
148
|
+
if (resolveGameServicesAuthorityMode(mode) === 'production') {
|
|
149
|
+
return isPublicGameServicesUrl(baseUrl);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const { protocol } = new URL(baseUrl);
|
|
153
|
+
return protocol === 'https:' || protocol === 'http:';
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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
|
+
}
|