@mpgd/cli 0.57.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capacitor-shell-starter.d.ts +30 -0
- package/dist/capacitor-shell-starter.js +243 -11
- package/dist/index.js +170 -11
- package/dist/legal-site-output.d.ts +18 -0
- package/dist/legal-site-output.js +46 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +1663 -899
- package/dist/native-deploy-run.js +4 -0
- package/dist/native-shell-store-policy.d.ts +123 -0
- package/dist/native-shell-store-policy.js +816 -0
- package/dist/release-state.d.ts +2 -0
- package/dist/release-state.js +4 -2
- package/dist/testflight-submission.d.ts +9 -0
- package/dist/testflight-submission.js +69 -0
- package/package.json +17 -17
- package/templates/lite-overlay/agent/acceptance.md +1 -1
- package/templates/lite-overlay/agent/brief.md +1 -1
- package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
- package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +12 -6
- package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
- package/templates/notifications-overlay/common/notifications/topics.json +3 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
- package/templates/notifications-overlay/common/src/platform/notifications.ts +17 -3
- package/templates/phaser-game/README.md +72 -0
- package/templates/phaser-game/agent/acceptance.md +4 -1
- package/templates/phaser-game/agent/brief.md +14 -4
- package/templates/phaser-game/gitignore +11 -0
- package/templates/phaser-game/legal/delete-account.html +133 -0
- package/templates/phaser-game/legal/privacy.html +111 -12
- package/templates/phaser-game/legal/support.html +1 -0
- package/templates/phaser-game/legal/terms.html +1 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/env.d.ts +6 -0
- package/templates/phaser-game/src/i18n/messages.ts +86 -1
- package/templates/phaser-game/src/main.ts +69 -8
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
- package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
- package/templates/phaser-game/src/platform/gameServices.ts +55 -10
- package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
- package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
- package/templates/phaser-game/src/platform/serverSession.ts +8 -0
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
- package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
- package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
package/dist/release-state.d.ts
CHANGED
|
@@ -136,5 +136,7 @@ export declare function releaseNativeSubmissionLease(input: {
|
|
|
136
136
|
readonly environment?: NodeJS.ProcessEnv;
|
|
137
137
|
readonly signal?: AbortSignal;
|
|
138
138
|
}): Promise<NativeSubmissionCheckpoint>;
|
|
139
|
+
/** Exported for tests: the checkpoint status moves a store submission may make. */
|
|
140
|
+
export declare function allowedSubmissionTransition(from: NativeSubmissionStatus, to: NativeSubmissionStatus): boolean;
|
|
139
141
|
/** Validate persisted checkpoint shape before any remote mutation or recovery. */
|
|
140
142
|
export declare function assertNativeSubmissionCheckpoint(value: unknown): asserts value is NativeSubmissionCheckpoint;
|
package/dist/release-state.js
CHANGED
|
@@ -524,7 +524,8 @@ export async function releaseNativeSubmissionLease(input) {
|
|
|
524
524
|
return checkpoint;
|
|
525
525
|
});
|
|
526
526
|
}
|
|
527
|
-
|
|
527
|
+
/** Exported for tests: the checkpoint status moves a store submission may make. */
|
|
528
|
+
export function allowedSubmissionTransition(from, to) {
|
|
528
529
|
if (from === to) {
|
|
529
530
|
return true;
|
|
530
531
|
}
|
|
@@ -550,7 +551,8 @@ function allowedSubmissionTransition(from, to) {
|
|
|
550
551
|
'failed',
|
|
551
552
|
'action-required',
|
|
552
553
|
],
|
|
553
|
-
|
|
554
|
+
// A build waiting on an operator answer can still expire or fail processing.
|
|
555
|
+
'action-required': ['processing', 'testflight-ready', 'unknown', 'failed'],
|
|
554
556
|
};
|
|
555
557
|
return allowed[from]?.includes(to) ?? false;
|
|
556
558
|
}
|
|
@@ -44,6 +44,15 @@ export type AscJsonRunner = (args: readonly string[], timeoutMs: number) => Prom
|
|
|
44
44
|
export declare function submitVerifiedIosBuild(input: IosTestFlightSubmissionInput): Promise<IosTestFlightSubmissionResult>;
|
|
45
45
|
/** Internal mock boundary used by credential-free submission tests. */
|
|
46
46
|
export declare function submitVerifiedIosBuildWithRunner(input: IosTestFlightSubmissionInput, run: AscJsonRunner): Promise<IosTestFlightSubmissionResult>;
|
|
47
|
+
/**
|
|
48
|
+
* App Store Connect `BuildBetaDetail.internalBuildState`: only
|
|
49
|
+
* READY_FOR_BETA_TESTING and IN_BETA_TESTING let the build reach internal
|
|
50
|
+
* testers. Returns undefined for those, else the status to report.
|
|
51
|
+
*/
|
|
52
|
+
export declare function classifyInternalBuildState(state: string): {
|
|
53
|
+
readonly status: Extract<IosTestFlightStatus, 'processing' | 'action-required' | 'failed' | 'unknown'>;
|
|
54
|
+
readonly detail?: string;
|
|
55
|
+
} | undefined;
|
|
47
56
|
export declare function isolatedAscEnvironment(input: IosTestFlightSubmissionInput, directory: string, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
48
57
|
/** The package consumes one reviewed asc pin; it does not trust PATH or version text. */
|
|
49
58
|
export declare function verifyPinnedAscBinary(binary: string, pinFile?: string): Promise<void>;
|
|
@@ -228,6 +228,32 @@ async function submitVerifiedIosBuildResolved(input, run, { marketingVersion, bu
|
|
|
228
228
|
detail: 'App Store Connect returned an unknown processing state.',
|
|
229
229
|
};
|
|
230
230
|
}
|
|
231
|
+
// processingState VALID does not mean testers can install the build: its
|
|
232
|
+
// TestFlight internal state can still be processing or waiting for the
|
|
233
|
+
// export-compliance answer (asc `builds build-beta-detail view`).
|
|
234
|
+
let internalState;
|
|
235
|
+
try {
|
|
236
|
+
internalState = readInternalBuildState(await run(['builds', 'build-beta-detail', 'view', '--build-id', build.id], lookupTimeoutMs));
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
status: 'unknown',
|
|
242
|
+
...uploadReference,
|
|
243
|
+
buildId: build.id,
|
|
244
|
+
detail: `Build is processed, but its TestFlight state could not be read${failureReason(error)}.`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const blocked = classifyInternalBuildState(internalState);
|
|
248
|
+
if (blocked !== undefined) {
|
|
249
|
+
return {
|
|
250
|
+
...base,
|
|
251
|
+
status: blocked.status,
|
|
252
|
+
...uploadReference,
|
|
253
|
+
buildId: build.id,
|
|
254
|
+
...(blocked.detail === undefined ? {} : { detail: blocked.detail }),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
231
257
|
try {
|
|
232
258
|
const added = await run(['builds', 'add-groups', '--build-id', build.id, '--group', input.internalGroupId], lookupTimeoutMs);
|
|
233
259
|
assertGroupAssignment(added, build.id, input.internalGroupId);
|
|
@@ -331,6 +357,49 @@ async function lookupBuild(input, marketingVersion, buildNumber, run) {
|
|
|
331
357
|
}
|
|
332
358
|
return { id: item.id, processingState: item.attributes.processingState };
|
|
333
359
|
}
|
|
360
|
+
function readInternalBuildState(response) {
|
|
361
|
+
if (!isRecord(response) || !isRecord(response.data)
|
|
362
|
+
|| response.data.type !== 'buildBetaDetails'
|
|
363
|
+
|| !isRecord(response.data.attributes)
|
|
364
|
+
|| typeof response.data.attributes.internalBuildState !== 'string'
|
|
365
|
+
|| response.data.attributes.internalBuildState === '') {
|
|
366
|
+
throw new Error('asc builds build-beta-detail view returned an invalid JSON schema.');
|
|
367
|
+
}
|
|
368
|
+
return response.data.attributes.internalBuildState;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* App Store Connect `BuildBetaDetail.internalBuildState`: only
|
|
372
|
+
* READY_FOR_BETA_TESTING and IN_BETA_TESTING let the build reach internal
|
|
373
|
+
* testers. Returns undefined for those, else the status to report.
|
|
374
|
+
*/
|
|
375
|
+
export function classifyInternalBuildState(state) {
|
|
376
|
+
switch (state) {
|
|
377
|
+
case 'READY_FOR_BETA_TESTING':
|
|
378
|
+
case 'IN_BETA_TESTING':
|
|
379
|
+
return undefined;
|
|
380
|
+
case 'PROCESSING':
|
|
381
|
+
return { status: 'processing' };
|
|
382
|
+
case 'MISSING_EXPORT_COMPLIANCE':
|
|
383
|
+
return {
|
|
384
|
+
status: 'action-required',
|
|
385
|
+
detail: 'TestFlight is waiting for the export-compliance answer: answer it in App Store '
|
|
386
|
+
+ 'Connect, or ship ITSAppUsesNonExemptEncryption in Info.plist for later builds.',
|
|
387
|
+
};
|
|
388
|
+
case 'IN_EXPORT_COMPLIANCE_REVIEW':
|
|
389
|
+
return {
|
|
390
|
+
status: 'action-required',
|
|
391
|
+
detail: 'Apple is reviewing the export-compliance documentation.',
|
|
392
|
+
};
|
|
393
|
+
case 'PROCESSING_EXCEPTION':
|
|
394
|
+
case 'EXPIRED':
|
|
395
|
+
return { status: 'failed', detail: `TestFlight reported ${state}.` };
|
|
396
|
+
default:
|
|
397
|
+
return {
|
|
398
|
+
status: 'unknown',
|
|
399
|
+
detail: `TestFlight reported an unknown internal state ${state}.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
334
403
|
function findInternalGroup(response, groupId) {
|
|
335
404
|
return readDataArray(response, 'TestFlight groups list').some((value) => isRecord(value)
|
|
336
405
|
&& value.type === 'betaGroups' && value.id === groupId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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.
|
|
74
|
-
"@mpgd/platform": "0.28.0",
|
|
73
|
+
"@mpgd/catalog": "0.11.1",
|
|
75
74
|
"@mpgd/phaser-assets": "0.9.1",
|
|
76
|
-
"@mpgd/
|
|
75
|
+
"@mpgd/platform": "0.30.0",
|
|
76
|
+
"@mpgd/target-config": "0.23.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.32.1",
|
|
83
83
|
"typescript": "7.0.2",
|
|
84
|
-
"@mpgd/adapter-
|
|
85
|
-
"@mpgd/adapter-
|
|
86
|
-
"@mpgd/adapter-capacitor": "0.
|
|
87
|
-
"@mpgd/adapter-
|
|
88
|
-
"@mpgd/adapter-
|
|
89
|
-
"@mpgd/
|
|
90
|
-
"@mpgd/
|
|
91
|
-
"@mpgd/
|
|
92
|
-
"@mpgd/
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/
|
|
95
|
-
"@mpgd/
|
|
96
|
-
"@mpgd/
|
|
84
|
+
"@mpgd/adapter-browser": "0.11.10",
|
|
85
|
+
"@mpgd/adapter-ait": "0.20.7",
|
|
86
|
+
"@mpgd/adapter-capacitor": "0.14.0",
|
|
87
|
+
"@mpgd/adapter-verse8": "0.7.11",
|
|
88
|
+
"@mpgd/adapter-devvit": "0.14.2",
|
|
89
|
+
"@mpgd/bridge": "0.16.2",
|
|
90
|
+
"@mpgd/analytics": "0.5.3",
|
|
91
|
+
"@mpgd/capacitor-storekit": "0.4.0",
|
|
92
|
+
"@mpgd/game-services": "0.30.0",
|
|
93
|
+
"@mpgd/capacitor-play-billing": "0.4.0",
|
|
94
|
+
"@mpgd/i18n": "0.6.24",
|
|
95
|
+
"@mpgd/game-runtime": "0.8.4",
|
|
96
|
+
"@mpgd/lite-shell": "0.1.0"
|
|
97
97
|
},
|
|
98
98
|
"main": "./dist/index.js",
|
|
99
99
|
"types": "./dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@ Preserve the generated JSON and Markdown in `artifacts/acceptance` or the target
|
|
|
11
11
|
- Verify renderer teardown removes its own listeners and never updates a replacement view from an old promise.
|
|
12
12
|
- Purchases and rewarded-ad callbacks never grant locally; verify authoritative ledger grants and durable reconciliation before release.
|
|
13
13
|
- Keep unknown native outcomes and late recovery observers; closing one lease must not release another owner's presentation or audio.
|
|
14
|
-
- Verify the game sets its real server session with `setStarterServerSession` and does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
|
|
14
|
+
- Verify the game sets its real server session with `setStarterServerSession` (the starter's `guestIdentity.ts` with `VITE_MPGD_IDENTITY_URL`), that store purchases bind to the server player rather than the native shell's local one, and that it does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
|
|
15
15
|
|
|
16
16
|
## Reddit Devvit
|
|
17
17
|
|
|
@@ -11,7 +11,7 @@ Read `AGENTS.md`, the agent manifest, and the `use-mpgd-kit` router before editi
|
|
|
11
11
|
- Keep recovery above renderer lifetimes; reconcile existing operations on startup and resume without opening SDK UI.
|
|
12
12
|
- Rewarded v2 needs an application-owned durable encrypted journal. An in-memory store is not recovery evidence.
|
|
13
13
|
- Reserve safe-area geometry once and preserve feed scrolling and browser touch gestures.
|
|
14
|
-
- Set the
|
|
14
|
+
- Set `VITE_MPGD_IDENTITY_URL` to the guest identity Worker: the shared bootstrap (`src/main.ts` and `src/platform/guestIdentity.ts`, the same files as the Phaser template) signs in a server-issued guest during bootstrap and calls `setStarterServerSession` with `createGuestServerSession`, and store purchases bind to that server player. A game with its own identity service calls `setStarterServerSession` before the game services are created instead. In a game created with `--providers notifications`, `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite dev server in the `development` mode is a local-only development session for notifications (`src/platform/developmentServerSession.ts`); it never reaches a build, commerce, or a deployed Worker.
|
|
15
15
|
|
|
16
16
|
Run `pnpm dev`, `pnpm check`, `pnpm build`, and `pnpm accept`.
|
|
17
17
|
Devvit inline and expanded entries both use the lite renderer. Keep optional engines behind explicit dynamic import boundaries and verify the inline budget.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { t } from '../i18n/messages';
|
|
2
|
+
import {
|
|
3
|
+
createStarterAccountDeletion,
|
|
4
|
+
deleteAccountConfirmText,
|
|
5
|
+
deleteAccountOutcomeText,
|
|
6
|
+
starterAccountDeletionConfirmation,
|
|
7
|
+
type StarterDeleteAccountOutcome,
|
|
8
|
+
} from '../platform/deleteAccount';
|
|
9
|
+
import { resolveStarterServerSession } from '../platform/serverSession';
|
|
10
|
+
import type { StarterContext } from '../runtime/gameContext';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Delete account for the lite template: a button over the game, shown where
|
|
14
|
+
* the game has a game-services backend (App Store guideline 5.1.1(v) and
|
|
15
|
+
* Google Play require deletion in the app). The first tap opens a panel that
|
|
16
|
+
* says what is deleted and kept; only "Delete permanently" deletes
|
|
17
|
+
* (`deleteAccount.ts`). Returns the disposer.
|
|
18
|
+
*/
|
|
19
|
+
export function mountDeleteAccountButton(mount: HTMLElement, context: StarterContext): () => void {
|
|
20
|
+
// No backend yet: read no session, which `once` would pin. A run whose guest sign-in
|
|
21
|
+
// failed at boot gets the backend after a later sign-in (`onReady`); mount then.
|
|
22
|
+
if (context.gameServices.accountDeletion === undefined) {
|
|
23
|
+
const onReady = (context.gameServices as { readonly onReady?: (listener: () => void) => () => void })
|
|
24
|
+
.onReady;
|
|
25
|
+
if (onReady === undefined) {
|
|
26
|
+
return () => undefined;
|
|
27
|
+
}
|
|
28
|
+
let dispose = (): void => undefined;
|
|
29
|
+
const stop = onReady(() => {
|
|
30
|
+
stop();
|
|
31
|
+
dispose = mountDeleteAccountButton(mount, context);
|
|
32
|
+
});
|
|
33
|
+
return () => {
|
|
34
|
+
stop();
|
|
35
|
+
dispose();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// The commerce client's session (`once`), so the button and the request agree.
|
|
39
|
+
const session = resolveStarterServerSession({ once: true });
|
|
40
|
+
const confirmation = starterAccountDeletionConfirmation(session);
|
|
41
|
+
const deletion = createStarterAccountDeletion({
|
|
42
|
+
...(context.gameServices.accountDeletion === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { client: context.gameServices.accountDeletion }),
|
|
45
|
+
storage: context.platform.storage,
|
|
46
|
+
...(context.platform.secureCredentials === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { secureCredentials: context.platform.secureCredentials }),
|
|
49
|
+
signedIn: () => session?.current() !== undefined,
|
|
50
|
+
// The identity service's confirmation, so an access token alone cannot delete the account.
|
|
51
|
+
...(confirmation === undefined ? {} : { requestConfirmation: confirmation }),
|
|
52
|
+
});
|
|
53
|
+
if (!deletion.available) {
|
|
54
|
+
if (context.gameServices.accountDeletion === undefined || session === undefined) {
|
|
55
|
+
return () => undefined;
|
|
56
|
+
}
|
|
57
|
+
// Signed in later (a guest session starts in the background): mount then.
|
|
58
|
+
let dispose = (): void => undefined;
|
|
59
|
+
const stop = session.onAuthenticated(() => {
|
|
60
|
+
stop();
|
|
61
|
+
dispose = mountDeleteAccountButton(mount, context);
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
stop();
|
|
65
|
+
dispose();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const locale = context.locale;
|
|
69
|
+
const previousPosition = mount.style.position;
|
|
70
|
+
if (getComputedStyle(mount).position === 'static') {
|
|
71
|
+
mount.style.position = 'relative';
|
|
72
|
+
}
|
|
73
|
+
const open = document.createElement('button');
|
|
74
|
+
open.type = 'button';
|
|
75
|
+
open.textContent = t(locale, 'deleteAccount');
|
|
76
|
+
open.style.cssText = 'position:absolute;top:8px;right:8px;padding:6px 10px;';
|
|
77
|
+
const panel = document.createElement('div');
|
|
78
|
+
panel.setAttribute('role', 'dialog');
|
|
79
|
+
panel.setAttribute('aria-modal', 'true');
|
|
80
|
+
panel.setAttribute('aria-labelledby', 'mpgd-delete-account-message');
|
|
81
|
+
// Shown by display, not `hidden`: an inline display would override it.
|
|
82
|
+
panel.style.cssText = 'position:absolute;inset:0;display:none;flex-direction:column;'
|
|
83
|
+
+ 'align-items:center;justify-content:center;gap:16px;padding:24px;box-sizing:border-box;'
|
|
84
|
+
+ 'background:#020617eb;color:#fff;font:15px system-ui,sans-serif;text-align:center;';
|
|
85
|
+
const message = document.createElement('p');
|
|
86
|
+
message.id = 'mpgd-delete-account-message';
|
|
87
|
+
message.style.cssText = 'margin:0;max-width:640px;white-space:pre-line;';
|
|
88
|
+
const status = document.createElement('p');
|
|
89
|
+
status.setAttribute('role', 'status');
|
|
90
|
+
status.setAttribute('aria-live', 'polite');
|
|
91
|
+
status.style.cssText = 'margin:0;min-height:1.4em;';
|
|
92
|
+
const confirm = document.createElement('button');
|
|
93
|
+
confirm.type = 'button';
|
|
94
|
+
confirm.textContent = t(locale, 'deleteAccountConfirmButton');
|
|
95
|
+
confirm.style.cssText = 'padding:8px 14px;background:#b91c1c;color:#fff;border:0;';
|
|
96
|
+
const cancel = document.createElement('button');
|
|
97
|
+
cancel.type = 'button';
|
|
98
|
+
cancel.textContent = t(locale, 'deleteAccountCancel');
|
|
99
|
+
cancel.style.cssText = 'padding:8px 14px;';
|
|
100
|
+
const actions = document.createElement('div');
|
|
101
|
+
actions.style.cssText = 'display:flex;gap:12px;';
|
|
102
|
+
actions.append(confirm, cancel);
|
|
103
|
+
panel.append(message, actions, status);
|
|
104
|
+
// Taps on the controls stay out of the game's own tap handling.
|
|
105
|
+
for (const element of [open, panel]) {
|
|
106
|
+
element.addEventListener('pointerdown', (event) => event.stopPropagation());
|
|
107
|
+
}
|
|
108
|
+
let deleting = false;
|
|
109
|
+
let signedOut = false;
|
|
110
|
+
const close = (): void => {
|
|
111
|
+
if (deleting) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (signedOut) {
|
|
115
|
+
// A fresh start drops every in-memory trace of the deleted player.
|
|
116
|
+
globalThis.location.reload();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
panel.style.display = 'none';
|
|
120
|
+
open.focus();
|
|
121
|
+
};
|
|
122
|
+
panel.addEventListener('keydown', (event) => {
|
|
123
|
+
if (event.key === 'Escape') {
|
|
124
|
+
close();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
open.addEventListener('click', () => {
|
|
128
|
+
if (deleting) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
status.textContent = '';
|
|
132
|
+
confirm.hidden = false;
|
|
133
|
+
cancel.textContent = t(locale, 'deleteAccountCancel');
|
|
134
|
+
message.textContent = deleteAccountConfirmText(locale, false);
|
|
135
|
+
panel.style.display = 'flex';
|
|
136
|
+
cancel.focus();
|
|
137
|
+
void deletion.hasPendingPurchase().then((pending) => {
|
|
138
|
+
if (pending) {
|
|
139
|
+
message.textContent = deleteAccountConfirmText(locale, true);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
cancel.addEventListener('click', close);
|
|
144
|
+
confirm.addEventListener('click', () => {
|
|
145
|
+
if (deleting) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
deleting = true;
|
|
149
|
+
confirm.setAttribute('aria-disabled', 'true');
|
|
150
|
+
status.textContent = t(locale, 'deleteAccountWorking');
|
|
151
|
+
void deletion.deleteAccount()
|
|
152
|
+
.catch((): StarterDeleteAccountOutcome => ({ outcome: 'failed', retryable: true }))
|
|
153
|
+
.then((result) => {
|
|
154
|
+
deleting = false;
|
|
155
|
+
confirm.removeAttribute('aria-disabled');
|
|
156
|
+
status.textContent = deleteAccountOutcomeText(locale, result);
|
|
157
|
+
if (result.outcome === 'deleted' || result.outcome === 'session-ended') {
|
|
158
|
+
// Signed out: Close restarts the game.
|
|
159
|
+
signedOut = true;
|
|
160
|
+
confirm.hidden = true;
|
|
161
|
+
open.hidden = true;
|
|
162
|
+
cancel.textContent = t(locale, 'deleteAccountClose');
|
|
163
|
+
cancel.focus();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
mount.append(open, panel);
|
|
168
|
+
return () => {
|
|
169
|
+
open.remove();
|
|
170
|
+
panel.remove();
|
|
171
|
+
mount.style.position = previousPosition;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { t } from '../i18n/messages';
|
|
2
|
+
import {
|
|
3
|
+
createStarterRestorePurchases,
|
|
4
|
+
restoreOutcomeText,
|
|
5
|
+
type StarterRestoreOutcome,
|
|
6
|
+
} from '../platform/restorePurchases';
|
|
7
|
+
import type { StarterContext } from '../runtime/gameContext';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Restore Purchases for the lite template: a button over the game, shown
|
|
11
|
+
* where the target sells through a store (App Review expects it while the
|
|
12
|
+
* catalog sells a non-consumable such as REMOVE_ADS). The grant still comes
|
|
13
|
+
* only from the backend ledger (`restorePurchases.ts`). Returns the disposer.
|
|
14
|
+
*/
|
|
15
|
+
export function mountRestorePurchasesButton(mount: HTMLElement, context: StarterContext): () => void {
|
|
16
|
+
const services = context.gameServices;
|
|
17
|
+
const restore = createStarterRestorePurchases({
|
|
18
|
+
gateway: context.platform,
|
|
19
|
+
// Read at tap time: a run that signs in late gets its recovery client then.
|
|
20
|
+
monetizationRecovery: () => services.monetizationRecovery,
|
|
21
|
+
...(services.ensureSession === undefined ? {} : { ensureSession: services.ensureSession }),
|
|
22
|
+
});
|
|
23
|
+
if (!restore.available) {
|
|
24
|
+
return () => undefined;
|
|
25
|
+
}
|
|
26
|
+
const previousPosition = mount.style.position;
|
|
27
|
+
if (getComputedStyle(mount).position === 'static') {
|
|
28
|
+
mount.style.position = 'relative';
|
|
29
|
+
}
|
|
30
|
+
const button = document.createElement('button');
|
|
31
|
+
button.type = 'button';
|
|
32
|
+
button.textContent = t(context.locale, 'restorePurchases');
|
|
33
|
+
button.style.cssText = 'position:absolute;bottom:8px;left:8px;padding:8px 12px;';
|
|
34
|
+
const status = document.createElement('p');
|
|
35
|
+
status.setAttribute('role', 'status');
|
|
36
|
+
status.style.cssText = 'position:absolute;bottom:44px;left:8px;margin:0;padding:4px 8px;'
|
|
37
|
+
+ 'color:#d6dee8;font:13px system-ui,sans-serif;border-radius:6px;pointer-events:none;';
|
|
38
|
+
status.setAttribute('aria-live', 'polite');
|
|
39
|
+
// In the accessibility tree from the start (empty), so its updates are announced.
|
|
40
|
+
const show = (text: string): void => {
|
|
41
|
+
status.textContent = text;
|
|
42
|
+
status.style.background = '#0b1726cc';
|
|
43
|
+
};
|
|
44
|
+
// Taps on the button stay out of the game's own tap handling.
|
|
45
|
+
button.addEventListener('pointerdown', (event) => event.stopPropagation());
|
|
46
|
+
button.addEventListener('click', () => {
|
|
47
|
+
// aria-disabled keeps keyboard focus; a second tap joins the running restore.
|
|
48
|
+
button.setAttribute('aria-disabled', 'true');
|
|
49
|
+
show(t(context.locale, 'restoreWorking'));
|
|
50
|
+
void restore.restore()
|
|
51
|
+
.catch((): StarterRestoreOutcome => ({ outcome: 'failed' }))
|
|
52
|
+
.then((result) => {
|
|
53
|
+
button.removeAttribute('aria-disabled');
|
|
54
|
+
show(restoreOutcomeText(context.locale, result));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
mount.append(button, status);
|
|
58
|
+
return () => {
|
|
59
|
+
button.remove();
|
|
60
|
+
status.remove();
|
|
61
|
+
mount.style.position = previousPosition;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { bindGameAudio } from '@mpgd/game-runtime/audio';
|
|
2
2
|
import type { CanvasSurface, LiteShell } from '@mpgd/lite-shell';
|
|
3
3
|
|
|
4
|
+
import { mountDeleteAccountButton } from '../lite/deleteAccountButton';
|
|
5
|
+
import { mountRestorePurchasesButton } from '../lite/restorePurchasesButton';
|
|
4
6
|
import { createTapGame } from '../lite/tapGame';
|
|
5
7
|
import type { StarterContext } from './gameContext';
|
|
6
8
|
|
|
@@ -29,6 +31,9 @@ export function createStarterGame(input: {
|
|
|
29
31
|
console.error('[game-audio]', error);
|
|
30
32
|
},
|
|
31
33
|
});
|
|
34
|
+
// Mounted before the game so later overlays (such as a settings dialog) stack above it.
|
|
35
|
+
const disposeRestorePurchases = mountRestorePurchasesButton(mount, input.context);
|
|
36
|
+
const disposeDeleteAccount = mountDeleteAccountButton(mount, input.context);
|
|
32
37
|
const shell = createTapGame({ mount, title: '__GAME_TITLE__' });
|
|
33
38
|
let disposed = false;
|
|
34
39
|
const dispose = (): void => {
|
|
@@ -39,6 +44,9 @@ export function createStarterGame(input: {
|
|
|
39
44
|
globalThis.removeEventListener('pagehide', onPageHide);
|
|
40
45
|
audio.dispose();
|
|
41
46
|
input.context.gameRuntime.dispose();
|
|
47
|
+
// In reverse order of mounting: each restores the mount's position it saw.
|
|
48
|
+
disposeDeleteAccount();
|
|
49
|
+
disposeRestorePurchases();
|
|
42
50
|
shell.dispose();
|
|
43
51
|
};
|
|
44
52
|
const onPageHide = (event: PageTransitionEvent): void => {
|
|
@@ -40,12 +40,18 @@ left for you.
|
|
|
40
40
|
`mpgd target init capacitor --providers notifications` to install the two
|
|
41
41
|
plugins into the game-owned shell. Never install
|
|
42
42
|
`@capacitor/push-notifications` next to them.
|
|
43
|
-
6. Advertising on Android:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
6. Advertising on Android: an Android FCM notification cannot carry the
|
|
44
|
+
opt-out action that the Korean rules require on every advertising push
|
|
45
|
+
(a settings path in the text does not replace it), so the shell renders
|
|
46
|
+
advertising pushes itself. `mpgd target init capacitor --providers
|
|
47
|
+
notifications` declares the kit's renderer
|
|
48
|
+
(`dev.mpgd.capacitor.AdvertisingMessagingService`) in a newly generated
|
|
49
|
+
Android project; for an existing one it prints the manifest lines to add.
|
|
50
|
+
`notifications/deployment.json` sets `shellRendersAdvertising: true`,
|
|
51
|
+
which lets the sample `events` topic map to `android`. The backend sends
|
|
52
|
+
advertising only to an Android installation whose shell registered the
|
|
53
|
+
renderer, so an older build of your app receives none. Remove the
|
|
54
|
+
`android` mapping (and the setting) if you ship a shell without it.
|
|
49
55
|
7. Apps in Toss: register each functional-message template in the console and
|
|
50
56
|
add its code to the topic's `targets.ait` mapping
|
|
51
57
|
(`{ "templateSetCode": "...", "agreement": "required" }`). Apps in Toss
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createCapacitorNotificationServiceProvider,
|
|
4
4
|
loadCapacitorNotificationSdks,
|
|
5
5
|
openCapacitorNotificationSettings,
|
|
6
|
+
readCapacitorAdvertisingPushRenderer,
|
|
6
7
|
} from '@mpgd/adapter-capacitor/notifications';
|
|
7
8
|
|
|
8
9
|
import { starterNotificationTopics } from './notifications';
|
|
@@ -38,9 +39,8 @@ export async function createStarterCapacitorNotifications(
|
|
|
38
39
|
...sdks,
|
|
39
40
|
// Android channel names the player sees in system settings, in the
|
|
40
41
|
// device language. The provider creates a channel only for the topics
|
|
41
|
-
// mapped to Android; `mpgd-advertising
|
|
42
|
-
//
|
|
43
|
-
// player can turn off.
|
|
42
|
+
// mapped to Android; advertising has `mpgd-advertising`, a channel of
|
|
43
|
+
// its own the player can turn off.
|
|
44
44
|
channels: korean
|
|
45
45
|
? {
|
|
46
46
|
'mpgd-service': { name: '알림' },
|
|
@@ -54,6 +54,11 @@ export async function createStarterCapacitorNotifications(
|
|
|
54
54
|
},
|
|
55
55
|
advertisingOptOutTitle: korean ? '수신거부' : 'Unsubscribe',
|
|
56
56
|
openSettings: openCapacitorNotificationSettings,
|
|
57
|
+
// Android: the shell renders advertising pushes with the opt-out
|
|
58
|
+
// action when its messaging service is the kit's renderer, which
|
|
59
|
+
// `mpgd target init capacitor --providers notifications` declares; the
|
|
60
|
+
// backend sends advertising only to such an installation.
|
|
61
|
+
advertisingRenderer: readCapacitorAdvertisingPushRenderer,
|
|
57
62
|
}),
|
|
58
63
|
];
|
|
59
64
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveStarterHostedNotificationBackend,
|
|
10
10
|
type StarterNotificationBackend,
|
|
11
11
|
} from './notificationBackend';
|
|
12
|
+
import { onStarterAccountDeleted } from './deleteAccount';
|
|
12
13
|
import { resolveStarterServerSession } from './serverSession';
|
|
13
14
|
import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
|
|
14
15
|
import {
|
|
@@ -25,9 +26,13 @@ import {
|
|
|
25
26
|
* The starter topics' purposes follow the notifications RFC (open question 1):
|
|
26
27
|
* `daily-ready` and `streak-at-risk` are `requested` reminders the player
|
|
27
28
|
* turns on, `friend-challenge` is `service`, and `events` is the sample
|
|
28
|
-
* `advertising` topic
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* `advertising` topic. On Android an FCM notification cannot carry the
|
|
30
|
+
* opt-out action the Korean rules require, so the shell renders advertising
|
|
31
|
+
* pushes itself (`mpgd target init capacitor --providers notifications`
|
|
32
|
+
* declares the kit's renderer, and `notifications/deployment.json` sets
|
|
33
|
+
* `shellRendersAdvertising`); the backend sends one only to an installation
|
|
34
|
+
* whose shell has the renderer. A topic whose copy offers a reward, a
|
|
35
|
+
* benefit, or a promotion is advertising: declare it so, never `requested`.
|
|
31
36
|
*/
|
|
32
37
|
export const starterNotificationCatalog = notificationTopicsJson as NotificationTopicCatalog;
|
|
33
38
|
|
|
@@ -129,6 +134,15 @@ function starterNotificationBackend(platform: TargetConfiguredGateway): StarterN
|
|
|
129
134
|
backend = createStarterNotificationBackend(platform);
|
|
130
135
|
if (backend !== undefined) {
|
|
131
136
|
backends.set(platform, backend);
|
|
137
|
+
const { runtime } = backend;
|
|
138
|
+
// After an account deletion the device stops registering for the
|
|
139
|
+
// deleted player (the server already removed its endpoints), and the
|
|
140
|
+
// next session gets a fresh runtime.
|
|
141
|
+
const stop = onStarterAccountDeleted(async () => {
|
|
142
|
+
stop();
|
|
143
|
+
backends.delete(platform);
|
|
144
|
+
await runtime.unregister();
|
|
145
|
+
});
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
return backend;
|