@mpgd/adapter-capacitor 0.10.1 → 0.11.1

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.
@@ -0,0 +1,333 @@
1
+ import { bridgeNotificationsEventProtocol, createBridgeError, createBridgeEventSource, isBridgeNotificationsV2Method, } from '@mpgd/bridge';
2
+ import { assertBridgeNotificationsV2Payload, assertBridgeNotificationsV2Result, } from '@mpgd/bridge/notifications';
3
+ import { PlatformOperationError } from '@mpgd/platform';
4
+ import { assertNotificationEventEnvelope, isNotificationErrorCode, NotificationError, notificationsProtocol, notificationsProtocolVersion, toNotificationSubscriptionAdapter, } from '@mpgd/platform/notifications';
5
+ import { namesOtherProtocol } from './bridge-events.js';
6
+ /**
7
+ * In-process `NativeBridge` over a notifications v2 provider. Each
8
+ * `notifications.v2.*` request validates its payload (a payload the contract
9
+ * rejects answers `NOTIFICATIONS_INVALID_INPUT`) and its answer (one the
10
+ * contract rejects answers `NOTIFICATIONS_CONFIGURATION_REQUIRED`, because the
11
+ * provider broke it). A method the
12
+ * provider does not implement answers `NOTIFICATIONS_UNSUPPORTED`, except
13
+ * `openSettings`, which answers `unavailable`. Events go out through
14
+ * `createBridgeEventSource`, which attaches to the provider only for the
15
+ * first listener, so events the provider buffered reach it. Any other method
16
+ * answers `UNSUPPORTED_METHOD`.
17
+ */
18
+ export function createNotificationProviderBridge(input) {
19
+ const { provider } = input;
20
+ const events = createBridgeEventSource(bridgeNotificationsEventProtocol, provider);
21
+ const serve = async (method, payload) => {
22
+ const checked = checkedPayload(method, payload);
23
+ switch (method) {
24
+ case 'notifications.v2.getCapabilities':
25
+ return {
26
+ protocolVersion: notificationsProtocolVersion,
27
+ provider: provider.provider,
28
+ capabilities: await provider.getCapabilities(),
29
+ };
30
+ case 'notifications.v2.getPermission':
31
+ return provider.getPermission(checked);
32
+ case 'notifications.v2.requestPermission':
33
+ return provider.requestPermission(checked);
34
+ case 'notifications.v2.revokePermission':
35
+ return implemented(provider.revokePermission, method).call(provider, checked);
36
+ case 'notifications.v2.register':
37
+ return { registration: await implemented(provider.register, method).call(provider) };
38
+ case 'notifications.v2.scheduleLocal':
39
+ return implemented(provider.scheduleLocal, method).call(provider, checked);
40
+ case 'notifications.v2.cancelLocal':
41
+ await implemented(provider.cancelLocal, method).call(provider, checked);
42
+ return { cancelled: true };
43
+ case 'notifications.v2.listLocal':
44
+ return { pending: await implemented(provider.listLocal, method).call(provider) };
45
+ case 'notifications.v2.openSettings':
46
+ return {
47
+ status: provider.openSettings === undefined
48
+ ? 'unavailable'
49
+ : await provider.openSettings(),
50
+ };
51
+ default: {
52
+ // A method added to the list without a case here fails to compile.
53
+ const unhandled = method;
54
+ throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
55
+ message: `The notifications bridge does not serve ${String(unhandled)}.`,
56
+ });
57
+ }
58
+ }
59
+ };
60
+ return {
61
+ async request(request) {
62
+ if (!isBridgeNotificationsV2Method(request.method)) {
63
+ return createBridgeError(request.id, 'UNSUPPORTED_METHOD', `${request.method} is not a notifications v2 method.`);
64
+ }
65
+ const method = request.method;
66
+ let data;
67
+ try {
68
+ data = await serve(method, request.payload);
69
+ }
70
+ catch (error) {
71
+ return toBridgeError(request, error);
72
+ }
73
+ try {
74
+ return { id: request.id, ok: true, data: assertBridgeNotificationsV2Result(method, data) };
75
+ }
76
+ catch (error) {
77
+ return createBridgeError(request.id, 'NOTIFICATIONS_CONFIGURATION_REQUIRED', `Notifications provider returned invalid data for ${method}: ${error instanceof Error ? error.message : String(error)}`);
78
+ }
79
+ },
80
+ subscribe: (listener) => events.subscribe(listener),
81
+ };
82
+ }
83
+ /** The payload as the contract reads it, or `NOTIFICATIONS_INVALID_INPUT`; both sides of the bridge use it. */
84
+ function checkedPayload(method, payload) {
85
+ try {
86
+ return assertBridgeNotificationsV2Payload(method, payload);
87
+ }
88
+ catch (error) {
89
+ throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
90
+ message: `${method} payload is invalid: ${error instanceof Error ? error.message : String(error)}`,
91
+ cause: error,
92
+ });
93
+ }
94
+ }
95
+ function implemented(method, name) {
96
+ if (method === undefined) {
97
+ throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
98
+ message: `The notifications provider does not implement ${name}.`,
99
+ });
100
+ }
101
+ return method;
102
+ }
103
+ function toBridgeError(request, error) {
104
+ if (error instanceof PlatformOperationError) {
105
+ // A host code kept with `NOTIFICATIONS_CONFIGURATION_REQUIRED` crosses in
106
+ // the message: the bridge error has no field for it.
107
+ const hostCode = error instanceof NotificationError ? error.hostCode : undefined;
108
+ return createBridgeError(request.id, error.code, hostCode === undefined ? error.message : `${error.message} (host code ${hostCode})`, error.retryable);
109
+ }
110
+ return createBridgeError(request.id, 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', `${request.method} failed: ${error instanceof Error ? error.message : String(error)}`, true);
111
+ }
112
+ /**
113
+ * Registry entry for a notifications v2 provider, as
114
+ * `createCommerceServiceProvider` is for commerce: it declares no feature and
115
+ * no method, the nine `notifications.v2.*` methods are implied and served in
116
+ * process by `createNotificationProviderBridge`, and the gateway exposes
117
+ * `notificationsV2` and a v1 `notifications` adapter served from it.
118
+ */
119
+ export function createNotificationServiceProvider(input) {
120
+ const { notifications } = input;
121
+ // Copied entry by entry, `targets` included: an entry that still carries
122
+ // one names no single target, and the v1 adapter counts it as outside.
123
+ const topics = input.topics.map((topic) => ({ ...topic }));
124
+ const service = {
125
+ async getCapabilities() {
126
+ return copyCapabilities(assertBridgeNotificationsV2Result('notifications.v2.getCapabilities', {
127
+ protocolVersion: notificationsProtocolVersion,
128
+ provider: notifications.provider,
129
+ capabilities: await notifications.getCapabilities(),
130
+ }).capabilities);
131
+ },
132
+ assertResult(method, data) {
133
+ assertBridgeNotificationsV2Result(method, data);
134
+ },
135
+ createGatewaySurface({ request, subscribe }) {
136
+ const client = createBridgeNotificationProvider({
137
+ provider: notifications.provider,
138
+ serves: {
139
+ revokePermission: notifications.revokePermission !== undefined,
140
+ register: notifications.register !== undefined,
141
+ local: notifications.scheduleLocal !== undefined
142
+ && notifications.cancelLocal !== undefined
143
+ && notifications.listLocal !== undefined,
144
+ },
145
+ request,
146
+ subscribe,
147
+ });
148
+ return {
149
+ notificationsV2: client,
150
+ notifications: createNotificationSubscriptionAdapter(client, topics),
151
+ };
152
+ },
153
+ };
154
+ return {
155
+ id: input.id,
156
+ features: [],
157
+ methods: [],
158
+ notificationsV2: service,
159
+ bridge: createNotificationProviderBridge({ provider: notifications }),
160
+ async getAvailability() {
161
+ return {};
162
+ },
163
+ };
164
+ }
165
+ /**
166
+ * The v1 `NotificationSubscriptionAdapter` served from a v2 provider whose
167
+ * host is readable (RFC "Migration and PR plan" table, through
168
+ * `toNotificationSubscriptionAdapter`): every state comes from the host, a
169
+ * topic outside `topics` reads `configuration-required` and requests
170
+ * `unavailable`.
171
+ */
172
+ export function createNotificationSubscriptionAdapter(provider, topics) {
173
+ return toNotificationSubscriptionAdapter({
174
+ async getPermission(input) {
175
+ const snapshot = await provider.getPermission(input);
176
+ return {
177
+ ...snapshot,
178
+ sources: Object.fromEntries(Object.keys(snapshot.topics).map((topic) => [topic, 'host'])),
179
+ };
180
+ },
181
+ requestPermission: ({ topics: requested }) => provider.requestPermission({ topics: requested }),
182
+ }, topics);
183
+ }
184
+ /**
185
+ * `NotificationProvider` over a bridge that serves the `notifications.v2.*`
186
+ * methods and the event envelope. Answers are validated again on this side,
187
+ * so a transport that forgets to stays fail-closed. A bridge failure becomes
188
+ * a `NotificationError`: one with a notifications code keeps it, anything
189
+ * else is `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` with the failure's
190
+ * retryable flag. An answer the contract rejects is
191
+ * `NOTIFICATIONS_CONFIGURATION_REQUIRED`, which is not retryable, because the
192
+ * provider broke the contract. An event envelope that
193
+ * names another protocol is skipped; a malformed one is dropped with a
194
+ * warning, because it carries nothing the game could route.
195
+ *
196
+ * Events reach the listeners subscribed when they arrive. The Capacitor
197
+ * provider keeps events that arrive while no listener is attached for the
198
+ * next subscriber, so a game that subscribes per scene loses no open.
199
+ */
200
+ export function createBridgeNotificationProvider(input) {
201
+ const send = async (method, payload) => {
202
+ // Checked here too, so a payload the contract rejects never pauses the
203
+ // game for a request that cannot succeed.
204
+ checkedPayload(method, payload);
205
+ let data;
206
+ try {
207
+ data = await input.request(method, payload);
208
+ }
209
+ catch (error) {
210
+ throw toNotificationError(error);
211
+ }
212
+ try {
213
+ return assertBridgeNotificationsV2Result(method, data);
214
+ }
215
+ catch (error) {
216
+ // The provider broke the contract; a retry would not change that.
217
+ throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
218
+ message: `Bridge returned invalid data for ${method}.`,
219
+ cause: error,
220
+ });
221
+ }
222
+ };
223
+ const protocol = { protocol: notificationsProtocol };
224
+ return {
225
+ provider: input.provider,
226
+ async getCapabilities() {
227
+ return (await send('notifications.v2.getCapabilities', protocol)).capabilities;
228
+ },
229
+ // Async, so wrong input from a caller outside TypeScript rejects instead
230
+ // of throwing synchronously.
231
+ getPermission: async (request) => send('notifications.v2.getPermission', topicsPayload(request)),
232
+ requestPermission: async (request) => send('notifications.v2.requestPermission', topicsPayload(request)),
233
+ ...(input.serves.revokePermission
234
+ ? {
235
+ revokePermission: async (request) => send('notifications.v2.revokePermission', topicsPayload(request)),
236
+ }
237
+ : {}),
238
+ ...(input.serves.register
239
+ ? {
240
+ async register() {
241
+ return (await send('notifications.v2.register', protocol)).registration;
242
+ },
243
+ }
244
+ : {}),
245
+ ...(input.serves.local
246
+ ? {
247
+ scheduleLocal: (request) => send('notifications.v2.scheduleLocal', request),
248
+ async cancelLocal(request) {
249
+ // A missing list stays missing, so the payload check rejects it.
250
+ await send('notifications.v2.cancelLocal', {
251
+ keys: request?.keys,
252
+ });
253
+ },
254
+ async listLocal() {
255
+ return (await send('notifications.v2.listLocal', protocol)).pending;
256
+ },
257
+ }
258
+ : {}),
259
+ async openSettings() {
260
+ return (await send('notifications.v2.openSettings', protocol)).status;
261
+ },
262
+ subscribe(listener) {
263
+ return input.subscribe((envelope) => {
264
+ if (namesOtherProtocol(envelope, bridgeNotificationsEventProtocol)) {
265
+ return;
266
+ }
267
+ let event;
268
+ try {
269
+ event = assertNotificationEventEnvelope(envelope).event;
270
+ }
271
+ catch (error) {
272
+ console.warn('Dropped an invalid notifications event from the bridge.', error);
273
+ return;
274
+ }
275
+ listener(event);
276
+ });
277
+ },
278
+ };
279
+ }
280
+ /**
281
+ * Only `topics` crosses, since the payload validator refuses unknown keys. A
282
+ * missing request or list stays missing on purpose: the cast lets `send`'s
283
+ * payload validator reject it as `NOTIFICATIONS_INVALID_INPUT`.
284
+ */
285
+ function topicsPayload(request) {
286
+ return { topics: request?.topics };
287
+ }
288
+ function toNotificationError(error) {
289
+ if (error instanceof NotificationError) {
290
+ return error;
291
+ }
292
+ const code = typeof error === 'object' && error !== null
293
+ ? error.code
294
+ : undefined;
295
+ const options = {
296
+ ...(error instanceof Error ? { message: error.message } : {}),
297
+ // A transport failure keeps its retryability.
298
+ ...(error instanceof PlatformOperationError ? { retryable: error.retryable } : {}),
299
+ cause: error,
300
+ };
301
+ return new NotificationError(isNotificationErrorCode(code) ? code : 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', options);
302
+ }
303
+ /** A field-by-field copy, so the gateway never hands out a provider-owned block. */
304
+ function copyCapabilities(capabilities) {
305
+ const { permission, remote, local } = capabilities;
306
+ return {
307
+ availability: capabilities.availability,
308
+ permission: {
309
+ scope: permission.scope,
310
+ prompt: permission.prompt,
311
+ readable: permission.readable,
312
+ serverReadable: permission.serverReadable,
313
+ allowance: permission.allowance,
314
+ userGesture: permission.userGesture,
315
+ ...(permission.promptLimit === undefined ? {} : { promptLimit: permission.promptLimit }),
316
+ ...(permission.maxTopicsPerPrompt === undefined
317
+ ? {}
318
+ : { maxTopicsPerPrompt: permission.maxTopicsPerPrompt }),
319
+ settings: permission.settings,
320
+ },
321
+ remote: {
322
+ supported: remote.supported,
323
+ purposes: [...remote.purposes],
324
+ addressing: remote.addressing,
325
+ content: remote.content,
326
+ },
327
+ local: {
328
+ supported: local.supported,
329
+ ...(local.maxPending === undefined ? {} : { maxPending: local.maxPending }),
330
+ },
331
+ open: capabilities.open,
332
+ };
333
+ }
@@ -0,0 +1,118 @@
1
+ import { type AppPlugin } from '@capacitor/app';
2
+ import { type NotificationProvider, type NotificationProviderId, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
3
+ import type { LocalNotificationSdk, PushSdk } from './notifications-sdk.js';
4
+ /** The reference provider id the backend's FCM registry entry uses. */
5
+ export declare const capacitorNotificationProviderId = "fcm";
6
+ /**
7
+ * The notification category (iOS) and action type that carries the
8
+ * advertising opt-out action. An FCM send for an advertising topic names it
9
+ * (`apns.payload.aps.category`) so iOS shows the action.
10
+ */
11
+ export declare const capacitorAdvertisingActionTypeId = "mpgd-advertising";
12
+ /**
13
+ * Keys of the push data the provider reads. A send carries the topic and the
14
+ * topic's root-relative route in them; every other string entry reaches the
15
+ * game as the open's untrusted `data`.
16
+ */
17
+ export declare const capacitorNotificationDataKeys: Readonly<{
18
+ readonly topic: 'topic';
19
+ readonly route: 'route';
20
+ }>;
21
+ /** iOS keeps at most 64 pending local notifications per app. */
22
+ export declare const capacitorIosMaxPendingLocalNotifications = 64;
23
+ /** How long `openSettings` waits for the app to leave for the settings screen. */
24
+ export declare const capacitorSettingsLeaveTimeoutMs = 5000;
25
+ /** How long `openSettings` waits for the player to come back from settings. */
26
+ export declare const capacitorSettingsReturnTimeoutMs = 120000;
27
+ export type CapacitorNotificationAppEvents = Pick<AppPlugin, 'addListener'>;
28
+ export interface CapacitorNotificationChannelSettings {
29
+ readonly name: string;
30
+ readonly description?: string;
31
+ /** Android importance, 1 (min) to 5 (max); 3 when absent. */
32
+ readonly importance?: 1 | 2 | 3 | 4 | 5;
33
+ }
34
+ export interface CreateCapacitorNotificationProviderInput {
35
+ readonly platform: 'android' | 'ios';
36
+ /**
37
+ * The topics resolved for this target, as the runtime reads them
38
+ * (`resolveNotificationTopicsForTarget(catalog, 'android')` from
39
+ * `@mpgd/catalog`). A topic outside them answers `unavailable` to a request.
40
+ */
41
+ readonly topics: readonly NotificationTopicDeclaration[];
42
+ /** Remote delivery; absent means the provider declares none. */
43
+ readonly push?: PushSdk;
44
+ /** Local reminders and the permission read; absent means no local reminders. */
45
+ readonly local?: LocalNotificationSdk;
46
+ /** Defaults to `capacitorNotificationProviderId` ('fcm'). */
47
+ readonly provider?: NotificationProviderId;
48
+ /**
49
+ * Denials Android accepts before only settings can change the answer: 2 for
50
+ * a target SDK of 33 or later (Capacitor 8 projects target 36), 1 for an
51
+ * app that targets 32 or lower. iOS always declares 1.
52
+ */
53
+ readonly androidPromptLimit?: 1 | 2;
54
+ /**
55
+ * Names of the Android channels, by channel id. A topic uses its mapping's
56
+ * `channelId`, or the default channel of its purpose
57
+ * (`capacitorNotificationChannelId`).
58
+ */
59
+ readonly channels?: Readonly<Record<string, CapacitorNotificationChannelSettings>>;
60
+ /** The title of the advertising opt-out action; '수신거부' when absent. */
61
+ readonly advertisingOptOutTitle?: string;
62
+ /**
63
+ * Opens the OS notification settings for this app. Neither plugin can, so
64
+ * the shell supplies it (for example a small native method). Without it
65
+ * `permission.settings` is false and `openSettings` answers 'unavailable'.
66
+ */
67
+ readonly openSettings?: () => Promise<void>;
68
+ /** App state events; defaults to `App` from `@capacitor/app`. */
69
+ readonly app?: CapacitorNotificationAppEvents | null;
70
+ /** The device's IANA zone for registrations; defaults to `Intl`. */
71
+ readonly timeZone?: () => string | undefined;
72
+ /** The device locale for registrations; defaults to `navigator.language`. */
73
+ readonly locale?: () => string | undefined;
74
+ readonly settingsLeaveTimeoutMs?: number;
75
+ readonly settingsReturnTimeoutMs?: number;
76
+ /** Reports a native listener or setup failure; defaults to `console.warn`. */
77
+ readonly onError?: (error: unknown) => void;
78
+ }
79
+ /** The provider, with the topics it was created for (the v1 surface reads them). */
80
+ export interface CapacitorNotificationProvider extends NotificationProvider {
81
+ readonly topics: readonly NotificationTopicDeclaration[];
82
+ }
83
+ /**
84
+ * The Android channel a topic posts to: its mapping's `channelId`, or the
85
+ * default channel of its purpose (`mpgd-service`, `mpgd-requested`,
86
+ * `mpgd-advertising`), so advertising stays in a channel of its own that the
87
+ * player can turn off in system settings.
88
+ */
89
+ export declare function capacitorNotificationChannelId(topic: Pick<NotificationTopicDeclaration, 'purpose' | 'mapping'>): string;
90
+ /**
91
+ * The native id of a local reminder key: a stable positive 32-bit integer
92
+ * (FNV-1a), as Android requires. The provider probes the next id when another
93
+ * pending reminder already holds it, and finds reminders by the key it stores
94
+ * in `extra`, so a collision never replaces another reminder.
95
+ */
96
+ export declare function capacitorLocalNotificationId(key: string): number;
97
+ /**
98
+ * Creates the Capacitor notifications provider over the two plugin SDKs.
99
+ *
100
+ * - **Listeners first.** It registers every native listener synchronously,
101
+ * before any `await`, and keeps them for the process lifetime: Capacitor
102
+ * retains a plugin's events only until its first native listener, so a
103
+ * cold-start open reaches this provider, which keeps it for its first
104
+ * subscriber.
105
+ * - **Permission.** It reads `LocalNotifications.checkPermissions`, which
106
+ * reflects the system setting on every Android version, and maps the OS
107
+ * `denied` to `blocked`: on iOS after the one denial, on Android after the
108
+ * second. Only `requestPermission` shows the OS dialog; `scheduleLocal`
109
+ * answers `permission-required` instead.
110
+ * - **Local reminders.** Inexact (`isExactNotification: false`), on the
111
+ * topic's channel, at most 64 pending on iOS, keeping the nearest, one
112
+ * operation at a time. A `fireAt` already past posts at once, as both
113
+ * plugins do.
114
+ * - **Fails closed.** Without either SDK the provider is `unsupported`;
115
+ * without `push` it declares no remote delivery, without `local` no local
116
+ * reminders.
117
+ */
118
+ export declare function createCapacitorNotificationProvider(input: CreateCapacitorNotificationProviderInput): CapacitorNotificationProvider;