@mpgd/cli 0.70.0 → 0.71.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/index.js +12 -7
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +32 -1
- package/dist/notifications-starter.js +3 -0
- package/dist/store-presence-command.d.ts +6 -0
- package/dist/store-presence-command.js +15 -2
- package/dist/store-presence-play.d.ts +37 -0
- package/dist/store-presence-play.js +120 -4
- package/package.json +18 -18
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +13 -0
- package/templates/notifications-overlay/common/src/platform/launchNoticesBanner.ts +102 -0
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +6 -4
- package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +146 -1
- package/templates/notifications-overlay/common/src/platform/notifications.ts +91 -0
- package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +23 -1
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +29 -0
package/dist/index.js
CHANGED
|
@@ -41,9 +41,7 @@ import { promotePlayRelease } from './play-release-promote.js';
|
|
|
41
41
|
import { applyStoreProducts, planStoreProducts, saveStoreProductPlan, } from './store-products-command.js';
|
|
42
42
|
import { formatAppStoreProductPlan } from './store-products-app-store.js';
|
|
43
43
|
import { formatPlayProductPlan } from './store-products-play.js';
|
|
44
|
-
import { applyStorePresence, planStorePresence, saveStorePresencePlan, } from './store-presence-command.js';
|
|
45
|
-
import { formatPlayPresencePlan } from './store-presence-play.js';
|
|
46
|
-
import { formatAppStorePresencePlan } from './store-presence-app-store.js';
|
|
44
|
+
import { applyStorePresence, planStorePresence, renderStorePresencePlan, saveStorePresencePlan, } from './store-presence-command.js';
|
|
47
45
|
import { applyAppStoreReleaseCommand, changeAppStorePhasedReleaseCommand, planAppStoreReleaseCommand, publishAppStoreVersionCommand, readAppStoreReleaseStatusCommand, saveAppStoreReleasePlan, } from './app-store-release-command.js';
|
|
48
46
|
import { formatAppStoreReleasePlan } from './app-store-release.js';
|
|
49
47
|
import { changePlayRollout } from './play-release-rollout.js';
|
|
@@ -953,21 +951,28 @@ const storeCommand = defineI18n({
|
|
|
953
951
|
store: { type: 'string', required: true, description: 'google-play or app-store.' },
|
|
954
952
|
presence: { type: 'string', required: false, description: 'Store presence file path, relative to the current directory; defaults to mpgd.store-presence.json in the game.' },
|
|
955
953
|
out: { type: 'string', required: false, description: 'New plan JSON path for apply; existing files are not overwritten.' },
|
|
954
|
+
json: { type: 'boolean', required: false, description: 'Print the plan (warnings included) as one JSON document.' },
|
|
956
955
|
},
|
|
957
956
|
run: async (ctx) => {
|
|
958
957
|
const presence = readOptionalString(ctx.values.presence);
|
|
958
|
+
const json = ctx.values.json === true;
|
|
959
959
|
const result = await planStorePresence({
|
|
960
960
|
game: readOptionalString(ctx.values.game) ?? '.',
|
|
961
961
|
profile: readOptionalString(ctx.values.profile) ?? 'beta',
|
|
962
962
|
store: readRequiredCliOption(ctx.values.store, '--store'),
|
|
963
963
|
...(presence === undefined ? {} : { presenceFile: presence }),
|
|
964
964
|
});
|
|
965
|
-
|
|
966
|
-
? formatPlayPresencePlan(result.plan)
|
|
967
|
-
: formatAppStorePresencePlan(result.plan));
|
|
965
|
+
process.stdout.write(`${renderStorePresencePlan(result, json)}\n`);
|
|
968
966
|
const out = readOptionalString(ctx.values.out);
|
|
969
967
|
if (out !== undefined) {
|
|
970
|
-
|
|
968
|
+
// With --json, stdout carries only the JSON document.
|
|
969
|
+
const written = `Wrote store presence plan: ${saveStorePresencePlan(out, result)}`;
|
|
970
|
+
if (json) {
|
|
971
|
+
console.error(written);
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
console.info(written);
|
|
975
|
+
}
|
|
971
976
|
}
|
|
972
977
|
},
|
|
973
978
|
}),
|
|
@@ -4557,7 +4557,8 @@ var notificationSuppressionReasons = Object.freeze([
|
|
|
4557
4557
|
"quiet-hours",
|
|
4558
4558
|
"frequency-capped",
|
|
4559
4559
|
"provider-limit",
|
|
4560
|
-
"advertising-sender-missing"
|
|
4560
|
+
"advertising-sender-missing",
|
|
4561
|
+
"reconfirmation-overdue"
|
|
4561
4562
|
]);
|
|
4562
4563
|
var notificationInputBounds = Object.freeze({
|
|
4563
4564
|
keyLength: 2048,
|
|
@@ -4693,6 +4694,33 @@ var notificationNoticeVariantFallbacks = Object.freeze({
|
|
|
4693
4694
|
"consent-result.advertising.refused": "consent-result.advertising.withdrawn",
|
|
4694
4695
|
"consent-result.night.refused": "consent-result.night.withdrawn"
|
|
4695
4696
|
});
|
|
4697
|
+
var everyRequiredNoticeVariant = Object.freeze(notificationNoticeVariants.filter((variant) => notificationNoticeVariantFallbacks[variant] === void 0));
|
|
4698
|
+
var notificationReconfirmationListDays = 60;
|
|
4699
|
+
var notificationNoticeLeadDays = 7;
|
|
4700
|
+
var notificationRefreshedConsentKinds = Object.freeze([
|
|
4701
|
+
"advertising",
|
|
4702
|
+
"night"
|
|
4703
|
+
]);
|
|
4704
|
+
var koreanRefreshIntervalMonths = 24;
|
|
4705
|
+
var notificationKoreanConsentRefresh = deepFreeze({
|
|
4706
|
+
advertising: {
|
|
4707
|
+
intervalMonths: koreanRefreshIntervalMonths,
|
|
4708
|
+
noticeLeadDays: notificationReconfirmationListDays,
|
|
4709
|
+
pushLeadDays: notificationNoticeLeadDays,
|
|
4710
|
+
onOverdue: "pause"
|
|
4711
|
+
},
|
|
4712
|
+
night: {
|
|
4713
|
+
intervalMonths: koreanRefreshIntervalMonths,
|
|
4714
|
+
noticeLeadDays: notificationReconfirmationListDays,
|
|
4715
|
+
pushLeadDays: notificationNoticeLeadDays,
|
|
4716
|
+
onOverdue: "pause"
|
|
4717
|
+
}
|
|
4718
|
+
});
|
|
4719
|
+
var noConsentRefresh = Object.freeze({});
|
|
4720
|
+
var notificationKoreanNoticePolicy = deepFreeze({
|
|
4721
|
+
consentResults: true,
|
|
4722
|
+
refresh: notificationKoreanConsentRefresh
|
|
4723
|
+
});
|
|
4696
4724
|
function isNotificationCalendarDate(date) {
|
|
4697
4725
|
return !Number.isNaN(calendarDateMillis(date));
|
|
4698
4726
|
}
|
|
@@ -17213,6 +17241,7 @@ var phaserEdits = {
|
|
|
17213
17241
|
"import {",
|
|
17214
17242
|
" NotificationSettingsScene,",
|
|
17215
17243
|
" routeNotificationSettingsOpens,",
|
|
17244
|
+
" showNotificationLaunchNotices,",
|
|
17216
17245
|
"} from '../scenes/NotificationSettingsScene';",
|
|
17217
17246
|
""
|
|
17218
17247
|
].join("\n")
|
|
@@ -17227,6 +17256,8 @@ var phaserEdits = {
|
|
|
17227
17256
|
" ownGameAudio(game, input.context);",
|
|
17228
17257
|
" // The advertising opt-out action and consent notices open the settings screen.",
|
|
17229
17258
|
" routeNotificationSettingsOpens(game, input.context);",
|
|
17259
|
+
" // Listed consent notices, once per launch, without pausing the game.",
|
|
17260
|
+
" showNotificationLaunchNotices(game, input.context);",
|
|
17230
17261
|
""
|
|
17231
17262
|
].join("\n")
|
|
17232
17263
|
}
|
|
@@ -280,6 +280,7 @@ const phaserEdits = {
|
|
|
280
280
|
'import {',
|
|
281
281
|
' NotificationSettingsScene,',
|
|
282
282
|
' routeNotificationSettingsOpens,',
|
|
283
|
+
' showNotificationLaunchNotices,',
|
|
283
284
|
"} from '../scenes/NotificationSettingsScene';",
|
|
284
285
|
'',
|
|
285
286
|
].join('\n'),
|
|
@@ -294,6 +295,8 @@ const phaserEdits = {
|
|
|
294
295
|
' ownGameAudio(game, input.context);',
|
|
295
296
|
' // The advertising opt-out action and consent notices open the settings screen.',
|
|
296
297
|
' routeNotificationSettingsOpens(game, input.context);',
|
|
298
|
+
' // Listed consent notices, once per launch, without pausing the game.',
|
|
299
|
+
' showNotificationLaunchNotices(game, input.context);',
|
|
297
300
|
'',
|
|
298
301
|
].join('\n'),
|
|
299
302
|
},
|
|
@@ -35,6 +35,12 @@ export interface StorePresencePorts {
|
|
|
35
35
|
}>;
|
|
36
36
|
}
|
|
37
37
|
export declare function planStorePresence(options: StorePresencePlanOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StorePresencePorts>): Promise<StorePresencePlanFile>;
|
|
38
|
+
/**
|
|
39
|
+
* What `store presence plan` prints: the plan for the terminal, or with
|
|
40
|
+
* `--json` the plan file itself (the same document `--out` saves, warnings
|
|
41
|
+
* included) as one JSON document.
|
|
42
|
+
*/
|
|
43
|
+
export declare function renderStorePresencePlan(result: StorePresencePlanFile, json: boolean): string;
|
|
38
44
|
export declare function saveStorePresencePlan(file: string, plan: StorePresencePlanFile): string;
|
|
39
45
|
export interface StorePresenceApplyOptions {
|
|
40
46
|
readonly planFile: string;
|
|
@@ -2,8 +2,8 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { openAscStorePresenceSession } from './app-store-presence-asc.js';
|
|
4
4
|
import { readStorePresenceSource, storePresencePlanCommand, } from './store-presence.js';
|
|
5
|
-
import { applyAppStorePresence, planAppStorePresence, } from './store-presence-app-store.js';
|
|
6
|
-
import { applyPlayPresence, planPlayPresence, } from './store-presence-play.js';
|
|
5
|
+
import { applyAppStorePresence, formatAppStorePresencePlan, planAppStorePresence, } from './store-presence-app-store.js';
|
|
6
|
+
import { applyPlayPresence, formatPlayPresencePlan, planPlayPresence, } from './store-presence-play.js';
|
|
7
7
|
import { assertStoreSyncStore, readStorePlanFile, relocateStoreSyncFile, resolveStoreSyncApp, StorePlanStaleError, writeStorePlanFile, } from './store-sync.js';
|
|
8
8
|
const defaultPorts = {
|
|
9
9
|
googlePlay: loadPlayStoreListing,
|
|
@@ -36,6 +36,19 @@ export async function planStorePresence(options, environment = process.env, port
|
|
|
36
36
|
plan,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* What `store presence plan` prints: the plan for the terminal, or with
|
|
41
|
+
* `--json` the plan file itself (the same document `--out` saves, warnings
|
|
42
|
+
* included) as one JSON document.
|
|
43
|
+
*/
|
|
44
|
+
export function renderStorePresencePlan(result, json) {
|
|
45
|
+
if (json) {
|
|
46
|
+
return JSON.stringify(result, null, 2);
|
|
47
|
+
}
|
|
48
|
+
return result.plan.store === 'google-play'
|
|
49
|
+
? formatPlayPresencePlan(result.plan)
|
|
50
|
+
: formatAppStorePresencePlan(result.plan);
|
|
51
|
+
}
|
|
39
52
|
export function saveStorePresencePlan(file, plan) {
|
|
40
53
|
return writeStorePlanFile(file, plan);
|
|
41
54
|
}
|
|
@@ -66,6 +66,13 @@ export interface PlayPresencePlan {
|
|
|
66
66
|
readonly imageRemovals: number;
|
|
67
67
|
readonly changed: boolean;
|
|
68
68
|
readonly notes: readonly string[];
|
|
69
|
+
/**
|
|
70
|
+
* Where Play's default language listing would fall short of Play's
|
|
71
|
+
* publishing requirements or recommendations once apply has run (Play's
|
|
72
|
+
* images plus the file's changes). Never blocks apply, and not part of
|
|
73
|
+
* the stale check.
|
|
74
|
+
*/
|
|
75
|
+
readonly warnings: readonly string[];
|
|
69
76
|
/** What the sync cannot do; the reviewer does it in Play Console. */
|
|
70
77
|
readonly manual: readonly string[];
|
|
71
78
|
/** Digest of everything read from Play. */
|
|
@@ -82,6 +89,36 @@ export interface PlayPresencePlanInput {
|
|
|
82
89
|
}
|
|
83
90
|
/** Read Play through a throwaway edit and plan; never writes. */
|
|
84
91
|
export declare function planPlayPresence(input: PlayPresencePlanInput, port: PlayStoreListingPort): Promise<PlayPresencePlan>;
|
|
92
|
+
/**
|
|
93
|
+
* Google Play's store listing graphics rules, from Play Console Help, "Add
|
|
94
|
+
* preview assets to showcase your app"
|
|
95
|
+
* (https://support.google.com/googleplay/android-developer/answer/9866151),
|
|
96
|
+
* checked 2026-11-01:
|
|
97
|
+
*
|
|
98
|
+
* - "You must provide an app icon to publish your store listing" and "You
|
|
99
|
+
* must provide a feature graphic to publish your store listing."
|
|
100
|
+
* - "You must provide a minimum of two screenshots across different device
|
|
101
|
+
* types to publish your store listing", and "up to 8 screenshots for each
|
|
102
|
+
* supported device type". The help page does not say which device
|
|
103
|
+
* types count; the sync asks for two in the phone set.
|
|
104
|
+
* - Tablets (7-inch and 10-inch) and Chromebooks: "you can add a minimum of
|
|
105
|
+
* 4 screenshots", 1,080 to 7,680 px, 16:9 or 9:16. That is a
|
|
106
|
+
* recommendation, not a publishing requirement; the help page states no
|
|
107
|
+
* effect of a missing tablet set on tablet visibility, and the large
|
|
108
|
+
* screen badge depends on the adaptive app quality tiers
|
|
109
|
+
* (https://developer.android.com/docs/quality-guidelines/adaptive-app-quality),
|
|
110
|
+
* not on screenshots.
|
|
111
|
+
*
|
|
112
|
+
* Translations need no graphics of their own: "If you add text translations
|
|
113
|
+
* without localized graphic assets, your app's graphic assets show from the
|
|
114
|
+
* default language"
|
|
115
|
+
* (https://support.google.com/googleplay/android-developer/answer/9844778).
|
|
116
|
+
* So only the default language is checked.
|
|
117
|
+
*/
|
|
118
|
+
export declare const googlePlayPublishingGraphics: Readonly<{
|
|
119
|
+
phoneScreenshots: 2;
|
|
120
|
+
largeScreenScreenshots: 4;
|
|
121
|
+
}>;
|
|
85
122
|
export interface PlayPresenceApplyResult {
|
|
86
123
|
readonly packageName: string;
|
|
87
124
|
readonly status: 'committed' | 'unchanged';
|
|
@@ -12,7 +12,7 @@ export async function planPlayPresence(input, port) {
|
|
|
12
12
|
const local = readLocalGraphics(input.directory, play);
|
|
13
13
|
const editId = await port.insertEdit(input.packageName, input.signal);
|
|
14
14
|
try {
|
|
15
|
-
return await planInEdit(input, play, local, port, editId);
|
|
15
|
+
return await planInEdit(input, play, local, port, editId, true);
|
|
16
16
|
}
|
|
17
17
|
finally {
|
|
18
18
|
await discardEdit(port, input.packageName, editId);
|
|
@@ -48,7 +48,7 @@ function readLocalGraphics(directory, play) {
|
|
|
48
48
|
}
|
|
49
49
|
return result;
|
|
50
50
|
}
|
|
51
|
-
async function planInEdit(input, play, local, port, editId) {
|
|
51
|
+
async function planInEdit(input, play, local, port, editId, checkPublishing) {
|
|
52
52
|
const { packageName, signal } = input;
|
|
53
53
|
const [details, remoteListings] = await Promise.all([
|
|
54
54
|
port.getDetails(packageName, editId, signal),
|
|
@@ -98,13 +98,39 @@ async function planInEdit(input, play, local, port, editId) {
|
|
|
98
98
|
.map((listing) => String(listing.language))
|
|
99
99
|
.filter((language) => !named.has(language.toLowerCase()))
|
|
100
100
|
.sort();
|
|
101
|
-
|
|
101
|
+
// An empty value is no default language.
|
|
102
|
+
const defaultLanguage = typeof details.defaultLanguage === 'string' && details.defaultLanguage.trim() !== ''
|
|
102
103
|
? details.defaultLanguage
|
|
103
104
|
: undefined;
|
|
104
105
|
if (defaultLanguage !== undefined && !named.has(defaultLanguage.toLowerCase())) {
|
|
105
106
|
notes.push(`Play's default language ${defaultLanguage} is not in the file; its listing stays as Play has it.`);
|
|
106
107
|
}
|
|
107
108
|
const plannedDetails = planDetails(details, play, notes);
|
|
109
|
+
if (checkPublishing && defaultLanguage === undefined) {
|
|
110
|
+
notes.push("Play reports no default language, so the listing's publishing graphics were not checked.");
|
|
111
|
+
}
|
|
112
|
+
let warnings = [];
|
|
113
|
+
if (checkPublishing && defaultLanguage !== undefined) {
|
|
114
|
+
try {
|
|
115
|
+
warnings = await publishingWarnings({
|
|
116
|
+
input,
|
|
117
|
+
editId,
|
|
118
|
+
port,
|
|
119
|
+
language: defaultLanguage,
|
|
120
|
+
remote: remoteByLanguage.get(defaultLanguage.toLowerCase()),
|
|
121
|
+
planned: planned.find((language) => language.language.toLowerCase() === defaultLanguage.toLowerCase()),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// Warnings are advisory: a failed read leaves them unchecked, but a
|
|
126
|
+
// cancelled plan stays cancelled.
|
|
127
|
+
if (signal?.aborted === true || (error instanceof Error && error.name === 'AbortError')) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
notes.push(`Play's ${defaultLanguage} images could not be read (${error instanceof Error ? error.message : String(error)}), `
|
|
131
|
+
+ "so the listing's publishing graphics were not checked.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
const manual = manualSteps(input.presence);
|
|
109
135
|
const imageRemovals = planned.reduce((sum, language) => sum + language.images.reduce((count, slot) => count + (slot.complete && isMultiImage(slot.imageType) ? slot.remove.length : 0), 0), 0);
|
|
110
136
|
const changed = plannedDetails !== undefined || planned.some((language) => language.action !== 'unchanged' || language.images.some((slot) => slot.action !== 'unchanged'));
|
|
@@ -130,6 +156,7 @@ async function planInEdit(input, play, local, port, editId) {
|
|
|
130
156
|
imageRemovals,
|
|
131
157
|
changed,
|
|
132
158
|
notes,
|
|
159
|
+
warnings,
|
|
133
160
|
manual,
|
|
134
161
|
remoteSha256,
|
|
135
162
|
desiredSha256,
|
|
@@ -266,6 +293,91 @@ function planDetails(details, play, notes) {
|
|
|
266
293
|
}
|
|
267
294
|
return { changes, write };
|
|
268
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Google Play's store listing graphics rules, from Play Console Help, "Add
|
|
298
|
+
* preview assets to showcase your app"
|
|
299
|
+
* (https://support.google.com/googleplay/android-developer/answer/9866151),
|
|
300
|
+
* checked 2026-11-01:
|
|
301
|
+
*
|
|
302
|
+
* - "You must provide an app icon to publish your store listing" and "You
|
|
303
|
+
* must provide a feature graphic to publish your store listing."
|
|
304
|
+
* - "You must provide a minimum of two screenshots across different device
|
|
305
|
+
* types to publish your store listing", and "up to 8 screenshots for each
|
|
306
|
+
* supported device type". The help page does not say which device
|
|
307
|
+
* types count; the sync asks for two in the phone set.
|
|
308
|
+
* - Tablets (7-inch and 10-inch) and Chromebooks: "you can add a minimum of
|
|
309
|
+
* 4 screenshots", 1,080 to 7,680 px, 16:9 or 9:16. That is a
|
|
310
|
+
* recommendation, not a publishing requirement; the help page states no
|
|
311
|
+
* effect of a missing tablet set on tablet visibility, and the large
|
|
312
|
+
* screen badge depends on the adaptive app quality tiers
|
|
313
|
+
* (https://developer.android.com/docs/quality-guidelines/adaptive-app-quality),
|
|
314
|
+
* not on screenshots.
|
|
315
|
+
*
|
|
316
|
+
* Translations need no graphics of their own: "If you add text translations
|
|
317
|
+
* without localized graphic assets, your app's graphic assets show from the
|
|
318
|
+
* default language"
|
|
319
|
+
* (https://support.google.com/googleplay/android-developer/answer/9844778).
|
|
320
|
+
* So only the default language is checked.
|
|
321
|
+
*/
|
|
322
|
+
export const googlePlayPublishingGraphics = Object.freeze({
|
|
323
|
+
phoneScreenshots: 2,
|
|
324
|
+
largeScreenScreenshots: 4,
|
|
325
|
+
});
|
|
326
|
+
const publishingSlots = [
|
|
327
|
+
'icon',
|
|
328
|
+
'featureGraphic',
|
|
329
|
+
'phoneScreenshots',
|
|
330
|
+
'sevenInchScreenshots',
|
|
331
|
+
'tenInchScreenshots',
|
|
332
|
+
];
|
|
333
|
+
/**
|
|
334
|
+
* Warnings for Play's default language listing, counted after apply: the
|
|
335
|
+
* plan's result for each slot the file names, Play's images for the others.
|
|
336
|
+
*/
|
|
337
|
+
async function publishingWarnings(input) {
|
|
338
|
+
const { packageName, signal } = input.input;
|
|
339
|
+
const plannedSlots = new Map((input.planned?.images ?? []).map((slot) => [slot.imageType, slot]));
|
|
340
|
+
const unread = input.remote === undefined
|
|
341
|
+
? []
|
|
342
|
+
: publishingSlots.filter((slot) => !plannedSlots.has(slot));
|
|
343
|
+
const remoteCounts = await mapWithConcurrency(unread, readConcurrency, async (imageType) => {
|
|
344
|
+
const images = await input.port.listImages({
|
|
345
|
+
packageName,
|
|
346
|
+
editId: input.editId,
|
|
347
|
+
language: String(input.remote?.language),
|
|
348
|
+
imageType,
|
|
349
|
+
...(signal === undefined ? {} : { signal }),
|
|
350
|
+
});
|
|
351
|
+
return [imageType, images.length];
|
|
352
|
+
});
|
|
353
|
+
const remote = new Map(remoteCounts);
|
|
354
|
+
const count = (imageType) => {
|
|
355
|
+
const slot = plannedSlots.get(imageType);
|
|
356
|
+
return slot === undefined
|
|
357
|
+
? (remote.get(imageType) ?? 0)
|
|
358
|
+
: slot.keep.length + slot.upload.length;
|
|
359
|
+
};
|
|
360
|
+
const prefix = `${input.planned?.language ?? input.language} (Play's default language)`;
|
|
361
|
+
const warnings = [];
|
|
362
|
+
if (count('icon') === 0) {
|
|
363
|
+
warnings.push(`${prefix} has no icon after apply; Play needs one to publish the store listing.`);
|
|
364
|
+
}
|
|
365
|
+
if (count('featureGraphic') === 0) {
|
|
366
|
+
warnings.push(`${prefix} has no featureGraphic after apply; Play needs a feature graphic to publish the store listing.`);
|
|
367
|
+
}
|
|
368
|
+
const phone = count('phoneScreenshots');
|
|
369
|
+
if (phone < googlePlayPublishingGraphics.phoneScreenshots) {
|
|
370
|
+
warnings.push(`${prefix} has ${phone} phoneScreenshots after apply; Play needs at least `
|
|
371
|
+
+ `${googlePlayPublishingGraphics.phoneScreenshots} screenshots across device types to publish the store `
|
|
372
|
+
+ `listing, and the sync asks for ${googlePlayPublishingGraphics.phoneScreenshots} in the phone set.`);
|
|
373
|
+
}
|
|
374
|
+
if (count('sevenInchScreenshots') === 0 && count('tenInchScreenshots') === 0) {
|
|
375
|
+
warnings.push(`${prefix} has no sevenInchScreenshots or tenInchScreenshots after apply; Play does not require tablet `
|
|
376
|
+
+ `screenshots to publish, but recommends at least ${googlePlayPublishingGraphics.largeScreenScreenshots} `
|
|
377
|
+
+ '(1,080 to 7,680 px, 16:9 or 9:16) to show the app on tablets.');
|
|
378
|
+
}
|
|
379
|
+
return warnings;
|
|
380
|
+
}
|
|
269
381
|
function manualSteps(presence) {
|
|
270
382
|
const steps = [];
|
|
271
383
|
if (presence.privacyPolicyUrl !== undefined) {
|
|
@@ -312,7 +424,8 @@ export async function applyPlayPresence(input) {
|
|
|
312
424
|
// A commit that failed without a definitive answer may have landed.
|
|
313
425
|
let commitUncertain = false;
|
|
314
426
|
try {
|
|
315
|
-
|
|
427
|
+
// Warnings never block apply, so apply does not read Play for them.
|
|
428
|
+
const current = await planInEdit(input.plan, play, local, port, editId, false);
|
|
316
429
|
if (!current.changed) {
|
|
317
430
|
await discardEdit(port, packageName, editId);
|
|
318
431
|
return { packageName, status: 'unchanged', languages: [] };
|
|
@@ -485,6 +598,9 @@ export function formatPlayPresencePlan(plan) {
|
|
|
485
598
|
if (plan.imageRemovals > 0) {
|
|
486
599
|
lines.push(` IMAGE REMOVAL: ${plan.imageRemovals} image(s); apply needs --confirm-image-removal`);
|
|
487
600
|
}
|
|
601
|
+
for (const warning of plan.warnings) {
|
|
602
|
+
lines.push(` warning ${warning}`);
|
|
603
|
+
}
|
|
488
604
|
for (const note of plan.notes) {
|
|
489
605
|
lines.push(` note ${note}`);
|
|
490
606
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"parse5": "7.3.0",
|
|
71
71
|
"postcss": "8.5.28",
|
|
72
72
|
"postcss-value-parser": "4.2.0",
|
|
73
|
-
"@mpgd/catalog": "0.15.
|
|
74
|
-
"@mpgd/platform": "0.
|
|
75
|
-
"@mpgd/
|
|
76
|
-
"@mpgd/
|
|
73
|
+
"@mpgd/catalog": "0.15.3",
|
|
74
|
+
"@mpgd/platform": "0.37.0",
|
|
75
|
+
"@mpgd/phaser-assets": "0.10.0",
|
|
76
|
+
"@mpgd/target-config": "0.24.10"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@types/node": "^24.0.0",
|
|
@@ -81,19 +81,19 @@
|
|
|
81
81
|
"@types/yauzl": "3.4.0",
|
|
82
82
|
"ttsc": "0.34.2",
|
|
83
83
|
"typescript": "7.0.2",
|
|
84
|
-
"@mpgd/adapter-ait": "0.
|
|
85
|
-
"@mpgd/adapter-browser": "0.12.
|
|
86
|
-
"@mpgd/adapter-capacitor": "0.19.
|
|
87
|
-
"@mpgd/adapter-
|
|
88
|
-
"@mpgd/
|
|
89
|
-
"@mpgd/bridge": "0.
|
|
90
|
-
"@mpgd/capacitor-play-billing": "0.6.
|
|
91
|
-
"@mpgd/
|
|
92
|
-
"@mpgd/
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/
|
|
95
|
-
"@mpgd/
|
|
96
|
-
"@mpgd/
|
|
84
|
+
"@mpgd/adapter-ait": "0.22.0",
|
|
85
|
+
"@mpgd/adapter-browser": "0.12.7",
|
|
86
|
+
"@mpgd/adapter-capacitor": "0.19.4",
|
|
87
|
+
"@mpgd/adapter-verse8": "0.7.23",
|
|
88
|
+
"@mpgd/analytics": "0.5.11",
|
|
89
|
+
"@mpgd/bridge": "0.20.0",
|
|
90
|
+
"@mpgd/capacitor-play-billing": "0.6.5",
|
|
91
|
+
"@mpgd/game-runtime": "0.10.4",
|
|
92
|
+
"@mpgd/capacitor-storekit": "0.7.4",
|
|
93
|
+
"@mpgd/game-services": "0.36.0",
|
|
94
|
+
"@mpgd/adapter-devvit": "0.15.1",
|
|
95
|
+
"@mpgd/i18n": "0.6.32",
|
|
96
|
+
"@mpgd/lite-shell": "0.1.0"
|
|
97
97
|
},
|
|
98
98
|
"main": "./dist/index.js",
|
|
99
99
|
"types": "./dist/index.d.ts",
|
|
@@ -15,6 +15,7 @@ left for you.
|
|
|
15
15
|
| `src/platform/capacitorNotifications.ts` | The Android and iOS provider (FCM and local reminders), registered by the Capacitor gateways. |
|
|
16
16
|
| `src/platform/notificationSettings.ts` | The settings screen's logic: permission, reminders, advertising and night consent, notices. |
|
|
17
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/platform/launchNoticesBanner.ts` | The box that shows the listed consent notices once per launch. |
|
|
18
19
|
| `src/scenes/NotificationSettingsScene.ts` or `src/lite/notificationSettingsPanel.ts` | The settings screen the game renders. |
|
|
19
20
|
| `tools/validate-notifications.ts` | `pnpm validate:notifications`. |
|
|
20
21
|
|
|
@@ -137,6 +138,18 @@ advertising consent is granted, and turning advertising consent off also
|
|
|
137
138
|
withdraws a granted night consent in the same call (two records, two result
|
|
138
139
|
notices).
|
|
139
140
|
|
|
141
|
+
**Consent notices at launch.** Once per launch, after the notifications
|
|
142
|
+
runtime starts (10 seconds at most), the game reads the consent notices the
|
|
143
|
+
server lists and, when there is one, shows them in a small box at the top
|
|
144
|
+
that does not pause the game, overdue ones first, and records each one as
|
|
145
|
+
given once the player can see it: on screen while the page is visible
|
|
146
|
+
(`showStarterLaunchNotices`). A player who signs in after launch gets the
|
|
147
|
+
read when the session becomes current. Under the Korean rules advertising to a player
|
|
148
|
+
pauses while a re-confirmation (every two years) is past its due date and
|
|
149
|
+
not recorded, so this is how a player who never opens the settings screen
|
|
150
|
+
ends the pause. Keep it, or show the notices in your own launch UI and
|
|
151
|
+
record them the same way.
|
|
152
|
+
|
|
140
153
|
## Try it locally
|
|
141
154
|
|
|
142
155
|
You can see the settings switches record consent before you have an
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStarterNoticeVisibility,
|
|
3
|
+
starterNotificationLabelsFor,
|
|
4
|
+
type StarterLaunchNotices,
|
|
5
|
+
} from './notificationSettings';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The launch notices (`showStarterLaunchNotices`) as a small box at the top
|
|
9
|
+
* of the page, over the game: a heading, each notice, and a close button.
|
|
10
|
+
* It takes no focus and pauses nothing; only its own area takes taps, and the
|
|
11
|
+
* game goes on under and around it. `onSeen` runs once for each notice the
|
|
12
|
+
* player can see: on screen, not scrolled out of the box, while the page is
|
|
13
|
+
* visible (`createStarterNoticeVisibility`), so a notice is recorded only
|
|
14
|
+
* once it was seen. Returns the disposer, which closing the box also runs.
|
|
15
|
+
*/
|
|
16
|
+
export function mountStarterLaunchNotices(
|
|
17
|
+
notices: StarterLaunchNotices['notices'],
|
|
18
|
+
locale: string,
|
|
19
|
+
options: {
|
|
20
|
+
readonly onSeen: (index: number) => void;
|
|
21
|
+
readonly parent?: HTMLElement;
|
|
22
|
+
},
|
|
23
|
+
): () => void {
|
|
24
|
+
const text = starterNotificationLabelsFor(locale);
|
|
25
|
+
const box = document.createElement('div');
|
|
26
|
+
box.setAttribute('role', 'status');
|
|
27
|
+
box.setAttribute('aria-label', text.notices);
|
|
28
|
+
Object.assign(box.style, {
|
|
29
|
+
position: 'fixed',
|
|
30
|
+
top: '12px',
|
|
31
|
+
left: '50%',
|
|
32
|
+
transform: 'translateX(-50%)',
|
|
33
|
+
width: 'min(560px, calc(100% - 32px))',
|
|
34
|
+
maxHeight: '50vh',
|
|
35
|
+
overflowY: 'auto',
|
|
36
|
+
boxSizing: 'border-box',
|
|
37
|
+
padding: '12px 16px',
|
|
38
|
+
borderRadius: '8px',
|
|
39
|
+
background: 'rgba(15, 23, 42, 0.94)',
|
|
40
|
+
color: '#d6dee8',
|
|
41
|
+
font: '14px Arial, sans-serif',
|
|
42
|
+
zIndex: '1000',
|
|
43
|
+
});
|
|
44
|
+
const heading = document.createElement('strong');
|
|
45
|
+
heading.textContent = text.notices;
|
|
46
|
+
box.append(heading);
|
|
47
|
+
const visibility = createStarterNoticeVisibility({
|
|
48
|
+
pageVisible: () => document.visibilityState === 'visible',
|
|
49
|
+
onSeen: options.onSeen,
|
|
50
|
+
});
|
|
51
|
+
const paragraphs = notices.map((notice, index) => {
|
|
52
|
+
const paragraph = document.createElement('p');
|
|
53
|
+
paragraph.style.whiteSpace = 'pre-line';
|
|
54
|
+
paragraph.textContent = `${notice.title}\n${notice.body}`;
|
|
55
|
+
paragraph.dataset['noticeIndex'] = String(index);
|
|
56
|
+
box.append(paragraph);
|
|
57
|
+
return paragraph;
|
|
58
|
+
});
|
|
59
|
+
const close = document.createElement('button');
|
|
60
|
+
close.type = 'button';
|
|
61
|
+
close.textContent = text.close;
|
|
62
|
+
box.append(close);
|
|
63
|
+
// A notice counts as seen once most of it shows in the box (its scroll
|
|
64
|
+
// area, which stays on screen), or, for one taller than the box, once it
|
|
65
|
+
// fills most of the box. Without IntersectionObserver every notice counts
|
|
66
|
+
// as on screen, so it is seen while the page is visible.
|
|
67
|
+
const observer = typeof IntersectionObserver === 'function'
|
|
68
|
+
? new IntersectionObserver((entries) => {
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const index = Number((entry.target as HTMLElement).dataset['noticeIndex']);
|
|
71
|
+
const shown = entry.intersectionRect.height;
|
|
72
|
+
const area = entry.rootBounds?.height ?? 0;
|
|
73
|
+
visibility.intersecting(
|
|
74
|
+
index,
|
|
75
|
+
entry.isIntersecting && (entry.intersectionRatio >= 0.6 || (area > 0 && shown >= area * 0.6)),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}, { root: box, threshold: Array.from({ length: 21 }, (_, step) => step / 20) })
|
|
79
|
+
: undefined;
|
|
80
|
+
const onVisibilityChange = (): void => visibility.pageVisibilityChanged();
|
|
81
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
82
|
+
(options.parent ?? document.body).append(box);
|
|
83
|
+
if (observer === undefined) {
|
|
84
|
+
paragraphs.forEach((_paragraph, index) => visibility.intersecting(index, true));
|
|
85
|
+
} else {
|
|
86
|
+
for (const paragraph of paragraphs) {
|
|
87
|
+
observer.observe(paragraph);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let disposed = false;
|
|
91
|
+
const dispose = (): void => {
|
|
92
|
+
if (disposed) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
disposed = true;
|
|
96
|
+
observer?.disconnect();
|
|
97
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
98
|
+
box.remove();
|
|
99
|
+
};
|
|
100
|
+
close.addEventListener('click', dispose);
|
|
101
|
+
return dispose;
|
|
102
|
+
}
|
|
@@ -31,6 +31,8 @@ import type { TargetConfiguredGateway } from '@mpgd/target-config';
|
|
|
31
31
|
export interface StarterNotificationBackend {
|
|
32
32
|
readonly runtime: NotificationRuntime;
|
|
33
33
|
listConsent(): Promise<readonly NotificationConsentRecord[]>;
|
|
34
|
+
/** Settles once the runtime has started, or failed to (logged); it never rejects. */
|
|
35
|
+
readonly started?: Promise<void>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** The server-backed session the Worker client needs (see `serverSession.ts`). */
|
|
@@ -153,10 +155,10 @@ export function createStarterWorkerNotificationBackend(
|
|
|
153
155
|
},
|
|
154
156
|
});
|
|
155
157
|
// It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
|
|
156
|
-
runtime.start().catch((error: unknown) => {
|
|
158
|
+
const started = runtime.start().catch((error: unknown) => {
|
|
157
159
|
console.error('[notifications] the runtime could not start.', error);
|
|
158
160
|
});
|
|
159
|
-
return { runtime, listConsent: () => client.listConsent() };
|
|
161
|
+
return { runtime, listConsent: () => client.listConsent(), started };
|
|
160
162
|
} catch (error) {
|
|
161
163
|
// A configuration the client refuses turns notifications off, never the game.
|
|
162
164
|
input.log('[notifications] the notifications backend could not be created.', error);
|
|
@@ -261,10 +263,10 @@ export function createStarterHostedNotificationBackend(
|
|
|
261
263
|
console.error('[notifications]', error);
|
|
262
264
|
},
|
|
263
265
|
});
|
|
264
|
-
runtime.start().catch((error: unknown) => {
|
|
266
|
+
const started = runtime.start().catch((error: unknown) => {
|
|
265
267
|
console.error('[notifications] the runtime could not start.', error);
|
|
266
268
|
});
|
|
267
|
-
return { runtime, listConsent: () => backend.listConsent() };
|
|
269
|
+
return { runtime, listConsent: () => backend.listConsent(), started };
|
|
268
270
|
} catch (error) {
|
|
269
271
|
// A configuration the runtime refuses turns notifications off, never the game.
|
|
270
272
|
input.log('[notifications] the notifications backend could not be created.', error);
|
|
@@ -732,7 +732,7 @@ function combinedState(
|
|
|
732
732
|
*/
|
|
733
733
|
function renderNotices(
|
|
734
734
|
listed: readonly NotificationNotice[],
|
|
735
|
-
input: StarterNotificationSettingsInput,
|
|
735
|
+
input: Pick<StarterNotificationSettingsInput, 'noticeCopy' | 'locale' | 'defaultLocale'>,
|
|
736
736
|
): readonly { readonly notice: NotificationNotice; readonly text: { readonly title: string; readonly body: string } }[] {
|
|
737
737
|
const noticeCopy = input.noticeCopy;
|
|
738
738
|
if (noticeCopy === undefined) {
|
|
@@ -759,3 +759,148 @@ function renderNotices(
|
|
|
759
759
|
];
|
|
760
760
|
});
|
|
761
761
|
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The consent notices shown once at launch, so a player who never opens the
|
|
765
|
+
* settings screen still sees them: under the Korean rules advertising pauses
|
|
766
|
+
* while a re-confirmation is overdue, until the game records it (notifications
|
|
767
|
+
* RFC "Consent refresh").
|
|
768
|
+
*/
|
|
769
|
+
export interface StarterLaunchNotices {
|
|
770
|
+
/** In the player's language, overdue ones (past their `dueBy`) first. */
|
|
771
|
+
readonly notices: readonly { readonly title: string; readonly body: string; readonly overdue: boolean }[];
|
|
772
|
+
/**
|
|
773
|
+
* Records the notices at these indexes of `notices` as given in the game,
|
|
774
|
+
* each at most once; call it for the ones the player has seen. Never throws.
|
|
775
|
+
*/
|
|
776
|
+
record(indexes: readonly number[]): Promise<void>;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export interface StarterLaunchNoticesInput {
|
|
780
|
+
/** The game's notifications runtime; nothing is read without one. */
|
|
781
|
+
readonly runtime: NotificationRuntime | undefined;
|
|
782
|
+
/** `gateway.notificationsV2`; nothing is read on a target without notifications. */
|
|
783
|
+
readonly provider: NotificationProvider | undefined;
|
|
784
|
+
/**
|
|
785
|
+
* Settles once the runtime has started; the notices are read after it, or
|
|
786
|
+
* after `startTimeoutMs` at the latest, since `notices.list()` checks the
|
|
787
|
+
* session and the capability itself.
|
|
788
|
+
*/
|
|
789
|
+
readonly started?: Promise<unknown>;
|
|
790
|
+
/** How long the read waits for `started`; 10 seconds by default. */
|
|
791
|
+
readonly startTimeoutMs?: number;
|
|
792
|
+
/** `notices.copy` of the catalog; without it no notice can be shown. */
|
|
793
|
+
readonly noticeCopy?: NotificationNoticeCopy;
|
|
794
|
+
readonly defaultLocale: string;
|
|
795
|
+
readonly locale: string;
|
|
796
|
+
/** The clock that says which notices are overdue; the device's by default. */
|
|
797
|
+
readonly now?: () => string;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** How long the launch read waits for the runtime to start, at most. */
|
|
801
|
+
export const starterLaunchNoticesStartTimeoutMs = 10_000;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Reads the listed notices once the runtime has started (or its start has
|
|
805
|
+
* taken `startTimeoutMs`) and renders them, or `undefined` when there is
|
|
806
|
+
* nothing to show (no runtime, no provider, no copy, no notice, or a read
|
|
807
|
+
* that failed, which is logged). It never throws and never prompts: the
|
|
808
|
+
* game goes on while it reads.
|
|
809
|
+
*/
|
|
810
|
+
export async function readStarterLaunchNotices(
|
|
811
|
+
input: StarterLaunchNoticesInput,
|
|
812
|
+
): Promise<StarterLaunchNotices | undefined> {
|
|
813
|
+
const { runtime } = input;
|
|
814
|
+
if (runtime === undefined || input.provider === undefined || input.noticeCopy === undefined) {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
if (input.started !== undefined) {
|
|
819
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
820
|
+
await Promise.race([
|
|
821
|
+
input.started,
|
|
822
|
+
new Promise<void>((resolve) => {
|
|
823
|
+
timer = setTimeout(resolve, input.startTimeoutMs ?? starterLaunchNoticesStartTimeoutMs);
|
|
824
|
+
}),
|
|
825
|
+
]).finally(() => clearTimeout(timer));
|
|
826
|
+
}
|
|
827
|
+
const shown = renderNotices(await runtime.notices.list(), input);
|
|
828
|
+
if (shown.length === 0) {
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
const today = toSeoulCalendarDate(input.now?.() ?? new Date().toISOString());
|
|
832
|
+
const ordered = shown
|
|
833
|
+
.map((entry) => ({ ...entry, overdue: entry.notice.dueBy < today }))
|
|
834
|
+
// Overdue first; the list's own order (by dueBy) otherwise.
|
|
835
|
+
.sort((left, right) => Number(right.overdue) - Number(left.overdue));
|
|
836
|
+
const recorded = new Set<number>();
|
|
837
|
+
return {
|
|
838
|
+
notices: ordered.map(({ text, overdue }) => ({ ...text, overdue })),
|
|
839
|
+
async record(indexes) {
|
|
840
|
+
const fresh = [...new Set(indexes)].flatMap((index) => {
|
|
841
|
+
const entry = recorded.has(index) ? undefined : ordered[index];
|
|
842
|
+
if (entry === undefined) {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
recorded.add(index);
|
|
846
|
+
return [entry.notice];
|
|
847
|
+
});
|
|
848
|
+
// `async` so a runtime that throws synchronously is a rejection here too.
|
|
849
|
+
const results = await Promise.allSettled(
|
|
850
|
+
fresh.map(async (notice) => runtime.notices.recordShown(notice)),
|
|
851
|
+
);
|
|
852
|
+
for (const result of results) {
|
|
853
|
+
if (result.status === 'rejected') {
|
|
854
|
+
// The settings screen lists it again, and the server may push it.
|
|
855
|
+
console.warn('[notifications] a launch notice could not be recorded.', result.reason);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
} catch (error) {
|
|
861
|
+
console.warn('[notifications] the launch notices could not be read.', error);
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Which launch notices the player has seen: one that is on screen while the
|
|
868
|
+
* page is visible. The banner reports each notice's intersection with the
|
|
869
|
+
* screen and each page visibility change; a notice on screen while the page
|
|
870
|
+
* is hidden counts once the page shows again. `onSeen` runs once per notice.
|
|
871
|
+
*/
|
|
872
|
+
export function createStarterNoticeVisibility(input: {
|
|
873
|
+
readonly pageVisible: () => boolean;
|
|
874
|
+
readonly onSeen: (index: number) => void;
|
|
875
|
+
}): {
|
|
876
|
+
readonly intersecting: (index: number, onScreen: boolean) => void;
|
|
877
|
+
readonly pageVisibilityChanged: () => void;
|
|
878
|
+
} {
|
|
879
|
+
const onScreen = new Set<number>();
|
|
880
|
+
const seen = new Set<number>();
|
|
881
|
+
const see = (index: number): void => {
|
|
882
|
+
if (!seen.has(index)) {
|
|
883
|
+
seen.add(index);
|
|
884
|
+
input.onSeen(index);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
return {
|
|
888
|
+
intersecting(index, visible) {
|
|
889
|
+
if (!visible) {
|
|
890
|
+
onScreen.delete(index);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
onScreen.add(index);
|
|
894
|
+
if (input.pageVisible()) {
|
|
895
|
+
see(index);
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
pageVisibilityChanged() {
|
|
899
|
+
if (input.pageVisible()) {
|
|
900
|
+
for (const index of onScreen) {
|
|
901
|
+
see(index);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
}
|
|
@@ -14,6 +14,8 @@ import { resolveStarterServerSession } from './serverSession';
|
|
|
14
14
|
import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
|
|
15
15
|
import {
|
|
16
16
|
createStarterNotificationSettings,
|
|
17
|
+
readStarterLaunchNotices,
|
|
18
|
+
type StarterLaunchNotices,
|
|
17
19
|
type StarterNotificationSettings,
|
|
18
20
|
} from './notificationSettings';
|
|
19
21
|
|
|
@@ -179,6 +181,95 @@ export function createStarterNotificationSettingsModel(
|
|
|
179
181
|
});
|
|
180
182
|
}
|
|
181
183
|
|
|
184
|
+
/** Gateways whose launch notices were read this launch. */
|
|
185
|
+
const launchNoticesRead = new WeakSet<TargetConfiguredGateway>();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Shows the consent notices the server lists, once per launch: once the
|
|
189
|
+
* runtime has started (or its start has taken 10 seconds), it reads the
|
|
190
|
+
* listed notices and, when there is one, calls `show` with them in the
|
|
191
|
+
* player's language, overdue ones first. `show` mounts them and calls
|
|
192
|
+
* `seen(index)` for each one the player can see; only those are recorded as
|
|
193
|
+
* given. Under the Korean rules advertising pauses while a re-confirmation
|
|
194
|
+
* is overdue, until the game records it, so a player who never opens the
|
|
195
|
+
* settings screen still ends the pause at the next launch.
|
|
196
|
+
*
|
|
197
|
+
* - **Signed out at launch.** With a server session whose player is not
|
|
198
|
+
* signed in yet, it reads once a session becomes current
|
|
199
|
+
* (`onAuthenticated`), while `signal` is not aborted.
|
|
200
|
+
* - **Never in the way.** It runs in the background: it never blocks the
|
|
201
|
+
* game or throws. A target without notifications or a game without a
|
|
202
|
+
* backend shows nothing. When `show` answers `false` (the game went away),
|
|
203
|
+
* nothing is recorded, and a later call in this process reads again.
|
|
204
|
+
*/
|
|
205
|
+
export function showStarterLaunchNotices(
|
|
206
|
+
platform: TargetConfiguredGateway,
|
|
207
|
+
input: {
|
|
208
|
+
readonly locale: string;
|
|
209
|
+
readonly show: (
|
|
210
|
+
notices: StarterLaunchNotices['notices'],
|
|
211
|
+
seen: (index: number) => void,
|
|
212
|
+
) => boolean;
|
|
213
|
+
/** Aborted when the game goes away: a pending sign-in read is dropped. */
|
|
214
|
+
readonly signal?: AbortSignal;
|
|
215
|
+
},
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
if (launchNoticesRead.has(platform) || input.signal?.aborted === true) {
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
}
|
|
220
|
+
const backend = starterNotificationBackend(platform);
|
|
221
|
+
if (backend === undefined) {
|
|
222
|
+
return Promise.resolve();
|
|
223
|
+
}
|
|
224
|
+
launchNoticesRead.add(platform);
|
|
225
|
+
const session = hostedBackendFor(platform) ? undefined : resolveStarterServerSession();
|
|
226
|
+
if (session !== undefined && session.current() === undefined) {
|
|
227
|
+
// Signed out at launch: read once a player signs in, while the game runs.
|
|
228
|
+
launchNoticesRead.delete(platform);
|
|
229
|
+
let stop: (() => void) | undefined;
|
|
230
|
+
const retry = (): void => {
|
|
231
|
+
stop?.();
|
|
232
|
+
input.signal?.removeEventListener('abort', cancel);
|
|
233
|
+
void showStarterLaunchNotices(platform, input);
|
|
234
|
+
};
|
|
235
|
+
const cancel = (): void => stop?.();
|
|
236
|
+
stop = session.onAuthenticated(retry);
|
|
237
|
+
input.signal?.addEventListener('abort', cancel, { once: true });
|
|
238
|
+
return Promise.resolve();
|
|
239
|
+
}
|
|
240
|
+
return (async () => {
|
|
241
|
+
const launch = await readStarterLaunchNotices({
|
|
242
|
+
runtime: backend.runtime,
|
|
243
|
+
provider: platform.notificationsV2,
|
|
244
|
+
...(backend.started === undefined ? {} : { started: backend.started }),
|
|
245
|
+
...(starterNotificationCatalog.notices === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: { noticeCopy: starterNotificationCatalog.notices.copy }),
|
|
248
|
+
defaultLocale: starterNotificationCatalog.defaultLocale,
|
|
249
|
+
locale: input.locale,
|
|
250
|
+
});
|
|
251
|
+
if (launch === undefined) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const shown = input.signal?.aborted !== true
|
|
255
|
+
&& input.show(launch.notices, (index) => {
|
|
256
|
+
void launch.record([index]);
|
|
257
|
+
});
|
|
258
|
+
if (!shown) {
|
|
259
|
+
// Not on screen: nothing was given, so a later mount may read again.
|
|
260
|
+
launchNoticesRead.delete(platform);
|
|
261
|
+
}
|
|
262
|
+
})().catch((error: unknown) => {
|
|
263
|
+
console.warn('[notifications] the launch notices could not be shown.', error);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether this gateway's notifications run over a hosted backend (Devvit), which needs no game session. */
|
|
268
|
+
function hostedBackendFor(platform: TargetConfiguredGateway): boolean {
|
|
269
|
+
return platform.notificationsV2 !== undefined
|
|
270
|
+
&& resolveStarterHostedNotificationBackend(platform.configTarget) !== undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
182
273
|
/**
|
|
183
274
|
* The game's one listener for notification opens (see
|
|
184
275
|
* `routeNotificationOpens` in `notificationOpens.ts`), over the runtime when
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mountStarterLaunchNotices } from '../platform/launchNoticesBanner';
|
|
1
2
|
import {
|
|
2
3
|
starterNotificationLabelsFor,
|
|
3
4
|
type StarterNotificationSettings,
|
|
@@ -5,6 +6,7 @@ import {
|
|
|
5
6
|
} from '../platform/notificationSettings';
|
|
6
7
|
import {
|
|
7
8
|
createStarterNotificationSettingsModel,
|
|
9
|
+
showStarterLaunchNotices,
|
|
8
10
|
subscribeStarterNotificationOpens,
|
|
9
11
|
} from '../platform/notifications';
|
|
10
12
|
import type { StarterContext } from '../runtime/gameContext';
|
|
@@ -15,7 +17,8 @@ import type { StarterContext } from '../runtime/gameContext';
|
|
|
15
17
|
* the advertising and night consent (`notificationSettings.ts` holds the
|
|
16
18
|
* logic). Every change is a tap; no switch starts on. A notification open
|
|
17
19
|
* whose route is the settings screen (the advertising opt-out action, a
|
|
18
|
-
* consent notice) opens the panel too
|
|
20
|
+
* consent notice) opens the panel too, and the listed consent notices show
|
|
21
|
+
* once per launch (`showStarterLaunchNotices`). Returns the disposer.
|
|
19
22
|
*/
|
|
20
23
|
export function mountNotificationSettingsPanel(mount: HTMLElement, context: StarterContext): () => void {
|
|
21
24
|
if (context.platform.notificationsV2 === undefined) {
|
|
@@ -192,8 +195,27 @@ export function mountNotificationSettingsPanel(mount: HTMLElement, context: Star
|
|
|
192
195
|
mount.style.position = 'relative';
|
|
193
196
|
}
|
|
194
197
|
mount.append(openButton, panel);
|
|
198
|
+
// Once per launch, the listed consent notices in a box that does not pause
|
|
199
|
+
// the game, each recorded as given once the player can see it: an overdue
|
|
200
|
+
// re-confirmation pauses advertising under the Korean rules until the game
|
|
201
|
+
// records it.
|
|
202
|
+
const lifetime = new AbortController();
|
|
203
|
+
let disposeLaunchNotices: (() => void) | undefined;
|
|
204
|
+
void showStarterLaunchNotices(context.platform, {
|
|
205
|
+
locale: context.locale,
|
|
206
|
+
signal: lifetime.signal,
|
|
207
|
+
show: (notices, seen) => {
|
|
208
|
+
if (lifetime.signal.aborted) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
disposeLaunchNotices = mountStarterLaunchNotices(notices, context.locale, { onSeen: seen });
|
|
212
|
+
return true;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
195
215
|
return () => {
|
|
216
|
+
lifetime.abort();
|
|
196
217
|
unsubscribe();
|
|
218
|
+
disposeLaunchNotices?.();
|
|
197
219
|
openButton.remove();
|
|
198
220
|
panel.remove();
|
|
199
221
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Phaser from 'phaser';
|
|
2
2
|
|
|
3
|
+
import { mountStarterLaunchNotices } from '../platform/launchNoticesBanner';
|
|
3
4
|
import {
|
|
4
5
|
starterNotificationLabelsFor,
|
|
5
6
|
type StarterNotificationLabels,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
} from '../platform/notificationSettings';
|
|
9
10
|
import {
|
|
10
11
|
createStarterNotificationSettingsModel,
|
|
12
|
+
showStarterLaunchNotices,
|
|
11
13
|
subscribeStarterNotificationOpens,
|
|
12
14
|
} from '../platform/notifications';
|
|
13
15
|
import { starterContextKey, type StarterContext } from '../runtime/gameContext';
|
|
@@ -70,6 +72,33 @@ export function routeNotificationSettingsOpens(game: Phaser.Game, context: Start
|
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Shows the consent notices the server lists once per launch, in a small box
|
|
77
|
+
* over the game that does not pause it, and records them as given (see
|
|
78
|
+
* `showStarterLaunchNotices`): a player who never opens the settings screen
|
|
79
|
+
* still sees an overdue re-confirmation, which under the Korean rules pauses
|
|
80
|
+
* advertising until it is recorded.
|
|
81
|
+
*/
|
|
82
|
+
export function showNotificationLaunchNotices(game: Phaser.Game, context: StarterContext): void {
|
|
83
|
+
let dispose: (() => void) | undefined;
|
|
84
|
+
const lifetime = new AbortController();
|
|
85
|
+
game.events.once('destroy', () => {
|
|
86
|
+
lifetime.abort();
|
|
87
|
+
dispose?.();
|
|
88
|
+
});
|
|
89
|
+
void showStarterLaunchNotices(context.platform, {
|
|
90
|
+
locale: context.locale,
|
|
91
|
+
signal: lifetime.signal,
|
|
92
|
+
show: (notices, seen) => {
|
|
93
|
+
if (lifetime.signal.aborted) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
dispose = mountStarterLaunchNotices(notices, context.locale, { onSeen: seen });
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
/**
|
|
74
103
|
* The game-rendered notification settings: the OS or host permission,
|
|
75
104
|
* reminder topics, and the advertising consent the Korean rules require
|