@mpgd/platform 0.26.0 → 0.27.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.
- package/dist/commerce-wallet.d.ts +9 -2
- package/dist/commerce-wallet.js +165 -24
- package/dist/notifications-conformance.d.ts +31 -114
- package/dist/notifications-conformance.js +25 -1332
- package/dist/notifications-runtime-support.d.ts +20 -0
- package/dist/notifications-runtime-support.js +96 -0
- package/dist/notifications-runtime.d.ts +262 -0
- package/dist/notifications-runtime.js +1715 -0
- package/package.json +5 -1
|
@@ -0,0 +1,1715 @@
|
|
|
1
|
+
import { assertNotificationConsentRecord, evaluateLocalNotificationReminder, findNotificationCopy, isNotificationTimestamp, isRecordableNotificationPermissionState, isValidNotificationTimeZone, latestNotificationConsent, normalizeNotificationTimestamp, notificationDefaultDeploymentTimeZone, NotificationError, notificationKeyIssue, notificationPermissionConsentKind, notificationPermissionStateOf, notificationRegistryMismatch, notificationTopicNeedsNoAgreement, resolveNotificationContent, routeNotificationReminder, } from './notifications.js';
|
|
2
|
+
import { bufferEvent, deliverEach, describeError, noticeIdentityOf, recordNoticeInputOf, reminderInputIssue, } from './notifications-runtime-support.js';
|
|
3
|
+
/**
|
|
4
|
+
* The notifications runtime (RFC "Runtime"): a target's
|
|
5
|
+
* `NotificationProvider` composed with the game backend that keeps consent
|
|
6
|
+
* records, endpoint registrations, server reminders, and notices for the
|
|
7
|
+
* authenticated player. Games call the runtime, never the provider directly.
|
|
8
|
+
*
|
|
9
|
+
* The backend is a port (`NotificationRuntimeBackend`): the game-services
|
|
10
|
+
* client in `@mpgd/game-services/notifications/client` implements it over the
|
|
11
|
+
* game-services Worker's session routes, and the conformance runner's fake
|
|
12
|
+
* backend implements it in memory. What survives a restart goes through
|
|
13
|
+
* another port (`NotificationRuntimeStorage`). Pure code: no Phaser, DOM,
|
|
14
|
+
* network, or SDK imports.
|
|
15
|
+
*/
|
|
16
|
+
const secondMs = 1000;
|
|
17
|
+
const dayMs = 24 * 60 * 60 * secondMs;
|
|
18
|
+
/** A registration identical to the last acknowledged one is skipped for this long. */
|
|
19
|
+
const registrationRefreshMs = 7 * dayMs;
|
|
20
|
+
/** The storage key `createNotificationRuntimeStorage` uses by default. */
|
|
21
|
+
export const defaultNotificationRuntimeStorageKey = 'mpgd:notifications-runtime';
|
|
22
|
+
/**
|
|
23
|
+
* A `NotificationRuntimeStorage` over the platform storage adapter
|
|
24
|
+
* (`gateway.storage`), one JSON value under `key`. The host owns its
|
|
25
|
+
* durability; a failed load or save surfaces from the runtime call that
|
|
26
|
+
* needed it as `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`.
|
|
27
|
+
*/
|
|
28
|
+
export function createNotificationRuntimeStorage(input) {
|
|
29
|
+
const key = input.key ?? defaultNotificationRuntimeStorageKey;
|
|
30
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
31
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
32
|
+
message: 'The notifications runtime storage key must be a non-empty string.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
async load() {
|
|
37
|
+
return (await input.storage.load({ key }))?.value;
|
|
38
|
+
},
|
|
39
|
+
async save(state) {
|
|
40
|
+
await input.storage.save({ key, value: state });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** A `NotificationRuntimeStorage` that lives in memory: nothing survives a restart. */
|
|
45
|
+
export function createMemoryNotificationRuntimeStorage() {
|
|
46
|
+
let stored = {};
|
|
47
|
+
return {
|
|
48
|
+
load: () => stored,
|
|
49
|
+
save(state) {
|
|
50
|
+
stored = state;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** A stored value as a runtime state; anything else reads as an empty state. */
|
|
55
|
+
export function notificationRuntimeStateOf(value) {
|
|
56
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
const record = value;
|
|
60
|
+
const registration = persistedRegistrationOf(record['registration']);
|
|
61
|
+
// The session unregister() left means nothing without the unbound state.
|
|
62
|
+
const unbound = record['unbound'] === true;
|
|
63
|
+
const unboundSession = unbound ? unboundSessionOf(record['unboundSession']) : undefined;
|
|
64
|
+
return {
|
|
65
|
+
...(registration === undefined ? {} : { registration }),
|
|
66
|
+
...(unbound ? { unbound: true } : {}),
|
|
67
|
+
...(unboundSession === undefined ? {} : { unboundSession }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function unboundSessionOf(value) {
|
|
71
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const { player, session } = value;
|
|
75
|
+
return typeof player === 'string' && player.length > 0 && typeof session === 'string' && session.length > 0
|
|
76
|
+
? { player, session }
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
function persistedRegistrationOf(value) {
|
|
80
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const record = value;
|
|
84
|
+
const registration = record['registration'];
|
|
85
|
+
const text = (field) => typeof field === 'string' && field.length > 0;
|
|
86
|
+
if (!text(record['player'])
|
|
87
|
+
|| !text(record['session'])
|
|
88
|
+
|| !text(record['at'])
|
|
89
|
+
|| !isNotificationTimestamp(record['at'])
|
|
90
|
+
|| typeof registration !== 'object'
|
|
91
|
+
|| registration === null
|
|
92
|
+
|| Array.isArray(registration)
|
|
93
|
+
|| !text(registration['provider'])) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const address = registration['address'];
|
|
97
|
+
if (typeof address !== 'object'
|
|
98
|
+
|| address === null
|
|
99
|
+
|| Array.isArray(address)
|
|
100
|
+
|| !text(address['kind'])) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
registration: registration,
|
|
105
|
+
player: record['player'],
|
|
106
|
+
session: record['session'],
|
|
107
|
+
at: record['at'],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const systemClock = { now: () => new Date().toISOString() };
|
|
111
|
+
function isPromiseLike(value) {
|
|
112
|
+
return (typeof value === 'object' || typeof value === 'function')
|
|
113
|
+
&& value !== null
|
|
114
|
+
&& typeof value.then === 'function';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Creates the notifications runtime (RFC "Runtime"): the provider composed
|
|
118
|
+
* with the backend for the authenticated session. The conformance runner's
|
|
119
|
+
* reference runtime is this one, so every vector exercises it.
|
|
120
|
+
*
|
|
121
|
+
* - Reads return the provider snapshot. A topic the host reads `unknown`
|
|
122
|
+
* takes the latest record, `sources` says which answered, and a
|
|
123
|
+
* game-rendered consent that is not opted in reads `prompt` until a
|
|
124
|
+
* decision is recorded and `denied` after it. A topic whose mapping needs no
|
|
125
|
+
* agreement reads `granted`. A read that observes a changed decided state
|
|
126
|
+
* records it, and every read still answers when the backend cannot.
|
|
127
|
+
* - Reads, requests, and revocations take each topic once, however often it
|
|
128
|
+
* is listed.
|
|
129
|
+
* - Requests throw `NOTIFICATIONS_USER_GESTURE_REQUIRED` without a gesture on
|
|
130
|
+
* a gesture-only provider. An unsupported provider answers `unavailable`, a
|
|
131
|
+
* provider in `action-required` answers that with its `requiredAction`, an
|
|
132
|
+
* undeclared or unmapped topic answers `unavailable`, a topic that needs no
|
|
133
|
+
* agreement answers `already-granted`, and a topic the host reads `blocked`
|
|
134
|
+
* answers `blocked`, all without reaching the host; every other topic
|
|
135
|
+
* reaches the prompt.
|
|
136
|
+
* - Records: decided states that differ from the latest record of their kind
|
|
137
|
+
* and scope, never `dismissed` or `action-required` answers. Device
|
|
138
|
+
* providers write none (the endpoint holds the permission), other app-scope
|
|
139
|
+
* providers write `delivery-permission`, and topic-scope providers write
|
|
140
|
+
* `host-agreement` with the host evidence, plus a `topic-preference` of
|
|
141
|
+
* `granted` when the player accepts a `requested` topic's agreement.
|
|
142
|
+
* Per-send acceptances are appended every time. A record belongs to the
|
|
143
|
+
* player signed in when the call or event began: one observed before
|
|
144
|
+
* authentication waits for it (only the latest per kind and scope), and one
|
|
145
|
+
* observed for a player waits for that player's session. A player action
|
|
146
|
+
* (`setTopicPreference`, `setAdvertisingConsent`) needs an authenticated
|
|
147
|
+
* player and throws `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` without one, as
|
|
148
|
+
* does every other backend call (a server reminder, `reminders.cancel`'s
|
|
149
|
+
* server part, and `notices`). A backend call belongs to the player signed
|
|
150
|
+
* in when it began and is refused when another player signed in since. A
|
|
151
|
+
* record the backend would refuse is dropped and its error surfaces.
|
|
152
|
+
* - Registrations: the provider's registration goes to the backend at
|
|
153
|
+
* authentication, on `register()`, on `endpoint-changed` (a rotated device
|
|
154
|
+
* address, or a platform user's changed locale or zone), and when a read
|
|
155
|
+
* or request, or a revocation, changes the device's decided permission; and
|
|
156
|
+
* at start and on reads once the last one the backend acknowledged is older
|
|
157
|
+
* than 7 days, belongs to another session, or was never acknowledged (a
|
|
158
|
+
* first registration that failed). An identical one for the same player and session is skipped
|
|
159
|
+
* for 7 days, except on `register()`. `unregister()` unbinds the
|
|
160
|
+
* installation from the player signed in when it was called and cancels the
|
|
161
|
+
* pending local reminders, even when the unbind fails, and nothing binds it
|
|
162
|
+
* again, across restarts, until `register()` or the next authentication. A
|
|
163
|
+
* session found at start or on a read other than the one `unregister()`
|
|
164
|
+
* left (any session, when none was signed in then) is such an
|
|
165
|
+
* authentication even when the runtime did not observe it. While bound, a
|
|
166
|
+
* registration acknowledged for another session is sent again for this
|
|
167
|
+
* one, and so is, at start, one for a session with nothing acknowledged.
|
|
168
|
+
* - Reminders: one key across both routes, and `cancel` removes it from each
|
|
169
|
+
* route that has it. Local ones pass the client gate steps (permission,
|
|
170
|
+
* then the topic preference from the backend, then quiet hours, which move
|
|
171
|
+
* them or refuse a topic that is not deferrable, then the topic's daily
|
|
172
|
+
* cap); server ones are checked and stored by the backend. Turning a topic off cancels its local
|
|
173
|
+
* reminders, a known permission other than granted cancels them, and start,
|
|
174
|
+
* authentication, and every backend read reconcile them with the catalog
|
|
175
|
+
* (a topic that no longer takes the local route loses its reminders), the
|
|
176
|
+
* latest preference, and permission.
|
|
177
|
+
* - Game listeners run after the runtime's own handling of an event; a
|
|
178
|
+
* listener that throws stops neither the others nor the runtime, and its
|
|
179
|
+
* error surfaces from `runUntilIdle`, as do failures of background work:
|
|
180
|
+
* the first one, or an `AggregateError` that counts them all. `stop()` and
|
|
181
|
+
* a later `start()` buffer events for the next first subscriber again.
|
|
182
|
+
*/
|
|
183
|
+
export function createNotificationRuntime(input) {
|
|
184
|
+
const { provider, backend } = input;
|
|
185
|
+
const clock = input.clock ?? systemClock;
|
|
186
|
+
const deploymentZone = input.timeZone ?? notificationDefaultDeploymentTimeZone;
|
|
187
|
+
const storage = input.storage ?? createMemoryNotificationRuntimeStorage();
|
|
188
|
+
/** The persisted state once loaded; mutated in place, then saved whole. */
|
|
189
|
+
let kept;
|
|
190
|
+
let loading;
|
|
191
|
+
/**
|
|
192
|
+
* The persisted state: synchronously when the storage answered
|
|
193
|
+
* synchronously (or has already answered), else once its load settles. A
|
|
194
|
+
* failed load is tried again on the next call.
|
|
195
|
+
*/
|
|
196
|
+
const loadState = () => {
|
|
197
|
+
if (kept !== undefined) {
|
|
198
|
+
return kept;
|
|
199
|
+
}
|
|
200
|
+
if (loading !== undefined) {
|
|
201
|
+
return loading;
|
|
202
|
+
}
|
|
203
|
+
const failed = (error) => new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
204
|
+
message: `The notifications runtime could not read its stored state: ${describeError(error)}`,
|
|
205
|
+
cause: error,
|
|
206
|
+
});
|
|
207
|
+
let loaded;
|
|
208
|
+
try {
|
|
209
|
+
loaded = storage.load();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw failed(error);
|
|
213
|
+
}
|
|
214
|
+
if (!isPromiseLike(loaded)) {
|
|
215
|
+
kept = { ...notificationRuntimeStateOf(loaded) };
|
|
216
|
+
return kept;
|
|
217
|
+
}
|
|
218
|
+
const pending = Promise.resolve(loaded).then((value) => {
|
|
219
|
+
kept ??= { ...notificationRuntimeStateOf(value) };
|
|
220
|
+
return kept;
|
|
221
|
+
}, (error) => {
|
|
222
|
+
loading = undefined;
|
|
223
|
+
throw failed(error);
|
|
224
|
+
});
|
|
225
|
+
loading = pending;
|
|
226
|
+
return pending;
|
|
227
|
+
};
|
|
228
|
+
/** Saves the whole state; answers synchronously when the storage does. */
|
|
229
|
+
const saveState = (current) => {
|
|
230
|
+
const failed = (error) => new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
231
|
+
message: `The notifications runtime could not save its state: ${describeError(error)}`,
|
|
232
|
+
cause: error,
|
|
233
|
+
});
|
|
234
|
+
const snapshot = {
|
|
235
|
+
...(current.registration === undefined ? {} : { registration: current.registration }),
|
|
236
|
+
...(current.unbound === true ? { unbound: true } : {}),
|
|
237
|
+
...(current.unbound === true && current.unboundSession !== undefined
|
|
238
|
+
? { unboundSession: current.unboundSession }
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
let saved;
|
|
242
|
+
try {
|
|
243
|
+
saved = storage.save(snapshot);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
throw failed(error);
|
|
247
|
+
}
|
|
248
|
+
return isPromiseLike(saved)
|
|
249
|
+
? Promise.resolve(saved).then(() => undefined, (error) => {
|
|
250
|
+
throw failed(error);
|
|
251
|
+
})
|
|
252
|
+
: undefined;
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
// Start reading now; a failure is thrown by the first call that needs the state.
|
|
256
|
+
const first = loadState();
|
|
257
|
+
if (isPromiseLike(first)) {
|
|
258
|
+
first.then(undefined, () => undefined);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// The next call that needs the state reads it again.
|
|
263
|
+
}
|
|
264
|
+
const listeners = new Set();
|
|
265
|
+
let buffer = [];
|
|
266
|
+
let gameSubscribed = false;
|
|
267
|
+
let unsubscribeProvider;
|
|
268
|
+
let unsubscribeAuthentication;
|
|
269
|
+
let configurationIssue;
|
|
270
|
+
/** The entry `loadRegistryEntry` answered, kept across starts. */
|
|
271
|
+
let loadedRegistryEntry;
|
|
272
|
+
/** The load in flight, so overlapping starts share one. */
|
|
273
|
+
let registryEntryLoad;
|
|
274
|
+
/** A `register()` held by a load in flight; the load's flush sends it unconditionally. */
|
|
275
|
+
let forceRegistration = false;
|
|
276
|
+
/** The device permission of the latest registration a load in flight held. */
|
|
277
|
+
let heldPermission;
|
|
278
|
+
/** The provider's capabilities as the latest `start()` read them. */
|
|
279
|
+
let startCapabilities;
|
|
280
|
+
/** The provider's latest registration, from `register()` or `endpoint-changed`. */
|
|
281
|
+
let latestRegistration;
|
|
282
|
+
const background = new Set();
|
|
283
|
+
/** The first background failure since the last `runUntilIdle`, and how many there were. */
|
|
284
|
+
let failure;
|
|
285
|
+
const fail = (error) => {
|
|
286
|
+
try {
|
|
287
|
+
const observed = input.onError?.(error);
|
|
288
|
+
if (isPromiseLike(observed)) {
|
|
289
|
+
observed.then(undefined, () => undefined);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// The observer's own failure is not the runtime's.
|
|
294
|
+
}
|
|
295
|
+
if (failure === undefined) {
|
|
296
|
+
failure = { error, count: 1 };
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
failure.count += 1;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const waitingRecords = [];
|
|
303
|
+
/** One promise chain per queue: records, registrations, and local reminder changes. */
|
|
304
|
+
const queues = {
|
|
305
|
+
record: Promise.resolve(),
|
|
306
|
+
register: Promise.resolve(),
|
|
307
|
+
local: Promise.resolve(),
|
|
308
|
+
};
|
|
309
|
+
/** Local reminders this runtime scheduled: the fallback for a provider without `listLocal`. */
|
|
310
|
+
const scheduledLocal = new Map();
|
|
311
|
+
const emit = (event) => {
|
|
312
|
+
if (!gameSubscribed) {
|
|
313
|
+
bufferEvent(buffer, event);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
for (const error of deliverEach([...listeners], [event])) {
|
|
317
|
+
fail(error);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const track = (work) => {
|
|
321
|
+
const tracked = work
|
|
322
|
+
.catch((error) => {
|
|
323
|
+
fail(error);
|
|
324
|
+
})
|
|
325
|
+
.finally(() => {
|
|
326
|
+
background.delete(tracked);
|
|
327
|
+
});
|
|
328
|
+
background.add(tracked);
|
|
329
|
+
};
|
|
330
|
+
/** Awaits follow-up work of a call that has already answered; a failure surfaces from `runUntilIdle`. */
|
|
331
|
+
const settle = async (work) => {
|
|
332
|
+
try {
|
|
333
|
+
await work;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
fail(error);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* Runs `work` after every earlier job of the same queue, in call order. A job
|
|
341
|
+
* never enqueues onto its own queue and waits for it, which would deadlock.
|
|
342
|
+
*/
|
|
343
|
+
const enqueue = (queue, work) => {
|
|
344
|
+
const run = queues[queue].then(work);
|
|
345
|
+
queues[queue] = run.catch(() => undefined);
|
|
346
|
+
return run;
|
|
347
|
+
};
|
|
348
|
+
const topicOf = (topic) => input.catalog.find((entry) => entry.id === topic);
|
|
349
|
+
const capabilitiesOf = async () => {
|
|
350
|
+
const capabilities = await provider.getCapabilities();
|
|
351
|
+
if (configurationIssue !== undefined) {
|
|
352
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
353
|
+
message: `The provider disagrees with its registry entry: ${configurationIssue}.`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return capabilities;
|
|
357
|
+
};
|
|
358
|
+
const sessionPlayer = () => backend.session()?.player;
|
|
359
|
+
// Consent records.
|
|
360
|
+
/** The session player's records, with that player's records still waiting; `undefined` without a session. */
|
|
361
|
+
const readRecords = async () => {
|
|
362
|
+
const session = backend.session();
|
|
363
|
+
// Nothing is read from a backend whose registry entry the provider disagrees with.
|
|
364
|
+
if (session === undefined || configurationIssue !== undefined) {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const records = await backend.listConsent();
|
|
368
|
+
return [
|
|
369
|
+
...records,
|
|
370
|
+
...waitingRecords
|
|
371
|
+
.filter((pending) => pending.player === undefined || pending.player === session.player)
|
|
372
|
+
.map((pending) => pending.record),
|
|
373
|
+
];
|
|
374
|
+
};
|
|
375
|
+
const addWaiting = (next) => {
|
|
376
|
+
if (next.player === undefined && next.mode !== 'each') {
|
|
377
|
+
// Before authentication only the latest per kind and scope is kept.
|
|
378
|
+
for (let index = waitingRecords.length - 1; index >= 0; index -= 1) {
|
|
379
|
+
const waiting = waitingRecords[index];
|
|
380
|
+
if (waiting !== undefined
|
|
381
|
+
&& waiting.player === undefined
|
|
382
|
+
&& waiting.mode !== 'each'
|
|
383
|
+
&& waiting.record.kind === next.record.kind
|
|
384
|
+
&& waiting.record.topic === next.record.topic) {
|
|
385
|
+
waitingRecords.splice(index, 1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
waitingRecords.push(next);
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Inside the record queue: writes the session player's waiting records and
|
|
393
|
+
* then `pending`, in order. What cannot be written waits for the next write.
|
|
394
|
+
*/
|
|
395
|
+
const writeRecords = async (pending) => {
|
|
396
|
+
for (const next of pending) {
|
|
397
|
+
addWaiting(next);
|
|
398
|
+
}
|
|
399
|
+
const session = backend.session();
|
|
400
|
+
// Records wait while the provider disagrees with its registry entry.
|
|
401
|
+
if (session === undefined || configurationIssue !== undefined) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let mine = waitingRecords.filter((waiting) => waiting.player === undefined || waiting.player === session.player);
|
|
405
|
+
if (registryEntryLoad !== undefined) {
|
|
406
|
+
// While the entry loads, only withdrawals and refusals go out, so the
|
|
407
|
+
// server stops sending at once; a record that grants waits for the
|
|
408
|
+
// check (the load writes it once the entry matches), and so does every
|
|
409
|
+
// record after it, keeping their order.
|
|
410
|
+
const firstGrant = mine.findIndex((waiting) => waiting.record.state === 'granted');
|
|
411
|
+
mine = firstGrant === -1 ? mine : mine.slice(0, firstGrant);
|
|
412
|
+
}
|
|
413
|
+
if (mine.length === 0) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const waiting of mine) {
|
|
417
|
+
waitingRecords.splice(waitingRecords.indexOf(waiting), 1);
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const records = [...(await backend.listConsent())];
|
|
421
|
+
while (mine.length > 0) {
|
|
422
|
+
if (backend.session()?.id !== session.id) {
|
|
423
|
+
// The session changed during the write: the rest waits for its
|
|
424
|
+
// player, ahead of any record held back behind it.
|
|
425
|
+
waitingRecords.unshift(...mine);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const next = mine[0];
|
|
429
|
+
if (next !== undefined) {
|
|
430
|
+
const latest = latestNotificationConsent(records, next.record.kind, next.record.topic);
|
|
431
|
+
if (next.mode !== 'changed' || latest?.state !== next.record.state) {
|
|
432
|
+
await backend.recordConsent(next.record);
|
|
433
|
+
records.push(next.record);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
mine.shift();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
// Back at the front, so a record held back while the entry loads stays after it.
|
|
441
|
+
waitingRecords.unshift(...mine);
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const queueRecords = (pending) => {
|
|
446
|
+
// A record the backend would refuse (a provider's malformed evidence) is
|
|
447
|
+
// dropped, so it never blocks the records after it; its error surfaces.
|
|
448
|
+
const valid = pending.filter((next) => {
|
|
449
|
+
try {
|
|
450
|
+
assertNotificationConsentRecord(next.record);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
fail(error);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
return valid.length === 0 && waitingRecords.length === 0
|
|
459
|
+
? Promise.resolve()
|
|
460
|
+
: enqueue('record', () => writeRecords(valid));
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* A player action is recorded for the authenticated player, never for
|
|
464
|
+
* whoever authenticates next.
|
|
465
|
+
*/
|
|
466
|
+
const requirePlayer = (operation) => {
|
|
467
|
+
const player = sessionPlayer();
|
|
468
|
+
if (player === undefined) {
|
|
469
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
470
|
+
message: `${operation} records a player action, which needs an authenticated player.`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return player;
|
|
474
|
+
};
|
|
475
|
+
/** Refuses a server call when another player signed in after the call began. */
|
|
476
|
+
const assertSamePlayer = (operation, player) => {
|
|
477
|
+
if (sessionPlayer() !== player) {
|
|
478
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
479
|
+
message: `${operation} began for another player; ask again for the player signed in now.`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* A server call needs the player who was signed in when it began, and that
|
|
485
|
+
* player still signed in; before sign-in it is temporarily unavailable.
|
|
486
|
+
*/
|
|
487
|
+
const requireSignedIn = (operation, player) => {
|
|
488
|
+
if (player === undefined) {
|
|
489
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
490
|
+
message: `${operation} calls the backend, which needs an authenticated player.`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
assertSamePlayer(operation, player);
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* The records the decided states in `observations` call for, for `player`:
|
|
497
|
+
* the one authenticated when the call began, or `undefined` before
|
|
498
|
+
* authentication.
|
|
499
|
+
*/
|
|
500
|
+
const permissionRecords = (capabilities, observations, evidence, player) => {
|
|
501
|
+
const pending = [];
|
|
502
|
+
const proof = evidence === undefined ? {} : { evidence };
|
|
503
|
+
for (const observation of observations) {
|
|
504
|
+
const state = observation.state;
|
|
505
|
+
if (!isRecordableNotificationPermissionState(state)) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const mode = capabilities.permission.allowance === 'per-send' && observation.accepted === true
|
|
509
|
+
? 'each'
|
|
510
|
+
: 'changed';
|
|
511
|
+
if (observation.topic === undefined) {
|
|
512
|
+
// Device providers keep the permission on the endpoint, not in records.
|
|
513
|
+
if (capabilities.permission.scope === 'app' && !isDeviceAddressing(capabilities)) {
|
|
514
|
+
pending.push({
|
|
515
|
+
mode,
|
|
516
|
+
player,
|
|
517
|
+
record: {
|
|
518
|
+
kind: 'delivery-permission',
|
|
519
|
+
state,
|
|
520
|
+
source: 'client-report',
|
|
521
|
+
at: clock.now(),
|
|
522
|
+
...proof,
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const topic = topicOf(observation.topic);
|
|
529
|
+
// Host policy, not the player, granted a topic that needs no agreement.
|
|
530
|
+
if (capabilities.permission.scope === 'topic' && topic !== undefined && !notificationTopicNeedsNoAgreement(topic)) {
|
|
531
|
+
pending.push({
|
|
532
|
+
mode,
|
|
533
|
+
player,
|
|
534
|
+
record: {
|
|
535
|
+
kind: 'host-agreement',
|
|
536
|
+
topic: observation.topic,
|
|
537
|
+
state,
|
|
538
|
+
source: 'client-report',
|
|
539
|
+
at: clock.now(),
|
|
540
|
+
...proof,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return pending;
|
|
546
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* The states a snapshot reports after the runtime's rules: a game-rendered
|
|
549
|
+
* consent not opted in reads `denied` once a decision is recorded (or made
|
|
550
|
+
* now), a topic that needs no agreement reads `granted`, and an `unknown`
|
|
551
|
+
* host read takes the latest record.
|
|
552
|
+
*/
|
|
553
|
+
const resolveStates = (capabilities, snapshot, topics, records, decidedNow = false) => {
|
|
554
|
+
const scope = capabilities.permission.scope;
|
|
555
|
+
const kind = notificationPermissionConsentKind(scope);
|
|
556
|
+
const decided = decidedNow
|
|
557
|
+
|| (records !== undefined && latestNotificationConsent(records, 'delivery-permission') !== undefined);
|
|
558
|
+
const resolve = (host, topic) => {
|
|
559
|
+
const declared = topic === undefined ? undefined : topicOf(topic);
|
|
560
|
+
if (scope === 'topic' && declared !== undefined && notificationTopicNeedsNoAgreement(declared)) {
|
|
561
|
+
return { state: 'granted', source: 'host' };
|
|
562
|
+
}
|
|
563
|
+
const state = capabilities.permission.prompt === 'game' && host === 'prompt' && decided
|
|
564
|
+
? 'denied'
|
|
565
|
+
: host;
|
|
566
|
+
const recorded = state === 'unknown' && records !== undefined
|
|
567
|
+
? latestRecordState(records, kind, scope === 'app' ? undefined : topic)
|
|
568
|
+
: undefined;
|
|
569
|
+
return recorded === undefined
|
|
570
|
+
? { state, source: 'host' }
|
|
571
|
+
: { state: recorded, source: 'record' };
|
|
572
|
+
};
|
|
573
|
+
const app = scope === 'app' && snapshot.app !== undefined
|
|
574
|
+
? resolve(snapshot.app, undefined)
|
|
575
|
+
: undefined;
|
|
576
|
+
const resolved = topics.map((topic) => [topic, resolve(notificationPermissionStateOf(snapshot, topic), topic)]);
|
|
577
|
+
return {
|
|
578
|
+
...(app === undefined ? {} : { app: app.state, appSource: app.source }),
|
|
579
|
+
topics: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.state])),
|
|
580
|
+
sources: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.source])),
|
|
581
|
+
};
|
|
582
|
+
};
|
|
583
|
+
/** The decided states the host itself reported, one per scope. */
|
|
584
|
+
const hostObservations = (capabilities, states, topics) => {
|
|
585
|
+
if (capabilities.permission.scope === 'app') {
|
|
586
|
+
return states.app === undefined || states.appSource !== 'host' ? [] : [{ state: states.app }];
|
|
587
|
+
}
|
|
588
|
+
return topics
|
|
589
|
+
.filter((topic) => states.sources[topic] === 'host')
|
|
590
|
+
.map((topic) => ({ topic, state: stateIn(states, topic) }));
|
|
591
|
+
};
|
|
592
|
+
// Registrations.
|
|
593
|
+
/**
|
|
594
|
+
* Inside the register queue: sends `registration` for the session unless
|
|
595
|
+
* the backend acknowledged the same one for this player and session within
|
|
596
|
+
* 7 days. One made before authentication is sent at authentication, and
|
|
597
|
+
* nothing binds after `unregister()` until `register()` or the next
|
|
598
|
+
* authentication, across restarts.
|
|
599
|
+
*/
|
|
600
|
+
const sendRegistration = async (registration, always) => {
|
|
601
|
+
latestRegistration = registration;
|
|
602
|
+
const session = backend.session();
|
|
603
|
+
if (session === undefined) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// Nothing goes out before the registry check: while an entry loads the
|
|
607
|
+
// registration waits (the load sends it once the entry matches, as
|
|
608
|
+
// forcefully as it was asked for), and a provider the entry disagrees
|
|
609
|
+
// with never sends its address.
|
|
610
|
+
if (registryEntryLoad !== undefined) {
|
|
611
|
+
forceRegistration ||= always;
|
|
612
|
+
// The permission the runtime decided for the device, which the flush keeps.
|
|
613
|
+
heldPermission = registration.address.kind === 'platform-user' ? undefined : registration.address.permission;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (configurationIssue !== undefined) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const persisted = kept ?? await loadState();
|
|
620
|
+
if (persisted.unbound === true) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const last = persisted.registration;
|
|
624
|
+
if (!always
|
|
625
|
+
&& last !== undefined
|
|
626
|
+
&& last.player === session.player
|
|
627
|
+
&& last.session === session.id
|
|
628
|
+
&& sameJson(last.registration, registration)
|
|
629
|
+
&& Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
await backend.registerEndpoint(registration);
|
|
633
|
+
const acknowledged = {
|
|
634
|
+
registration,
|
|
635
|
+
player: session.player,
|
|
636
|
+
session: session.id,
|
|
637
|
+
at: clock.now(),
|
|
638
|
+
};
|
|
639
|
+
// Kept only once saved: after a failed save the next identical one is sent again.
|
|
640
|
+
const saved = saveState({ ...persisted, registration: acknowledged });
|
|
641
|
+
if (saved !== undefined) {
|
|
642
|
+
await saved;
|
|
643
|
+
}
|
|
644
|
+
persisted.registration = acknowledged;
|
|
645
|
+
};
|
|
646
|
+
/**
|
|
647
|
+
* Registers the provider's current address or platform identity; `rebind`
|
|
648
|
+
* ends an `unregister()`. A `permission` the runtime decided (a
|
|
649
|
+
* game-rendered consent reads `denied` once withdrawn) replaces the one a
|
|
650
|
+
* device address carries.
|
|
651
|
+
*/
|
|
652
|
+
const registerCurrent = (always, rebind = false, permission) => enqueue('register', async () => {
|
|
653
|
+
if (rebind !== false) {
|
|
654
|
+
const persisted = kept ?? await loadState();
|
|
655
|
+
if (persisted.unbound === true) {
|
|
656
|
+
if (rebind === 'if-unobserved-sign-in' && !unobservedSignIn(persisted)) {
|
|
657
|
+
// An unregister() queued since the check left this very session.
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const { unboundSession, registration } = persisted;
|
|
661
|
+
delete persisted.unbound;
|
|
662
|
+
delete persisted.unboundSession;
|
|
663
|
+
// The backend dropped the endpoint at the unbind: the registration
|
|
664
|
+
// kept from before it is no longer acknowledged, so it is sent again.
|
|
665
|
+
delete persisted.registration;
|
|
666
|
+
try {
|
|
667
|
+
const saved = saveState(persisted);
|
|
668
|
+
if (saved !== undefined) {
|
|
669
|
+
await saved;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
// Memory goes back to what storage still holds.
|
|
674
|
+
persisted.unbound = true;
|
|
675
|
+
if (unboundSession !== undefined) {
|
|
676
|
+
persisted.unboundSession = unboundSession;
|
|
677
|
+
}
|
|
678
|
+
if (registration !== undefined) {
|
|
679
|
+
persisted.registration = registration;
|
|
680
|
+
}
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const capabilities = await provider.getCapabilities();
|
|
686
|
+
const register = provider.register;
|
|
687
|
+
if (!capabilities.remote.supported || capabilities.remote.addressing === 'none') {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// A provider without `register()` reports its address only through
|
|
691
|
+
// `endpoint-changed`: the latest one it reported is its registration.
|
|
692
|
+
const registration = (register === undefined ? undefined : await register.call(provider))
|
|
693
|
+
?? latestRegistration;
|
|
694
|
+
if (registration !== undefined) {
|
|
695
|
+
const { address } = registration;
|
|
696
|
+
await sendRegistration(permission === undefined || address.kind === 'platform-user' || address.permission === permission
|
|
697
|
+
? registration
|
|
698
|
+
: { ...registration, address: { ...address, permission } }, always);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
/**
|
|
702
|
+
* Whether the session signed in now, while unbound, is a sign-in after
|
|
703
|
+
* `unregister()`: any session when none was signed in at the unbind, else
|
|
704
|
+
* one other than the session it left.
|
|
705
|
+
*/
|
|
706
|
+
const unobservedSignIn = (persisted) => {
|
|
707
|
+
const session = backend.session();
|
|
708
|
+
const left = persisted.unboundSession;
|
|
709
|
+
return session !== undefined
|
|
710
|
+
&& (left === undefined || left.player !== session.player || left.session !== session.id);
|
|
711
|
+
};
|
|
712
|
+
/**
|
|
713
|
+
* Catches the registration up with the session signed in now. At start and
|
|
714
|
+
* on reads:
|
|
715
|
+
* - while unbound, a session other than the one `unregister()` left is a
|
|
716
|
+
* sign-in the runtime did not observe (before `start()`, or while
|
|
717
|
+
* stopped), so the installation binds again;
|
|
718
|
+
* - a registration acknowledged for another player or session is sent for
|
|
719
|
+
* this one, for the same reason;
|
|
720
|
+
* - a device registration acknowledged more than 7 days ago is sent again,
|
|
721
|
+
* so the endpoint of a device in use never goes stale.
|
|
722
|
+
* With nothing acknowledged yet, the registration is sent at start for a
|
|
723
|
+
* session that was already signed in, and on reads once the provider has
|
|
724
|
+
* an address whose registration failed.
|
|
725
|
+
*/
|
|
726
|
+
const refreshRegistration = async (atStart) => {
|
|
727
|
+
const session = backend.session();
|
|
728
|
+
if (session === undefined) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const persisted = kept ?? await loadState();
|
|
732
|
+
if (persisted.unbound === true) {
|
|
733
|
+
if (unobservedSignIn(persisted)) {
|
|
734
|
+
await registerCurrent(false, 'if-unobserved-sign-in');
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const last = persisted.registration;
|
|
739
|
+
if (last === undefined) {
|
|
740
|
+
// Nothing acknowledged yet: at start for a session already signed in,
|
|
741
|
+
// and on reads once the provider has an address whose registration
|
|
742
|
+
// failed (a Worker that answered 429 or could not be reached).
|
|
743
|
+
if (atStart || latestRegistration !== undefined) {
|
|
744
|
+
// The retry keeps the device permission the runtime decided last.
|
|
745
|
+
const address = latestRegistration?.address;
|
|
746
|
+
await registerCurrent(false, false, address === undefined || address.kind === 'platform-user' ? undefined : address.permission);
|
|
747
|
+
}
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const otherSession = last.player !== session.player || last.session !== session.id;
|
|
751
|
+
if (!otherSession
|
|
752
|
+
&& (Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs
|
|
753
|
+
|| !isDeviceAddressing(await provider.getCapabilities()))) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
await registerCurrent(false);
|
|
757
|
+
};
|
|
758
|
+
/** Re-registers when the device's decided permission differs from the latest registration's. */
|
|
759
|
+
const syncRegistration = async (capabilities, state) => {
|
|
760
|
+
if (!isDeviceAddressing(capabilities)
|
|
761
|
+
|| state === undefined
|
|
762
|
+
|| !isRecordableNotificationPermissionState(state)) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const address = (latestRegistration ?? (kept ?? await loadState()).registration?.registration)?.address;
|
|
766
|
+
if (address !== undefined && address.kind !== 'platform-user' && address.permission === state) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
await registerCurrent(false, false, state);
|
|
770
|
+
};
|
|
771
|
+
// Local reminders.
|
|
772
|
+
const listLocalEntries = async () => provider.listLocal === undefined ? [...scheduledLocal.values()] : provider.listLocal();
|
|
773
|
+
/** Inside the local queue: cancels the pending local reminders `refused` picks. */
|
|
774
|
+
const cancelLocalNow = async (refused) => {
|
|
775
|
+
const cancelLocal = provider.cancelLocal;
|
|
776
|
+
if (cancelLocal === undefined) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const keys = (await listLocalEntries()).filter(refused).map((entry) => entry.key);
|
|
780
|
+
if (keys.length > 0) {
|
|
781
|
+
await cancelLocal.call(provider, { keys });
|
|
782
|
+
for (const key of keys) {
|
|
783
|
+
scheduledLocal.delete(key);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
/**
|
|
788
|
+
* Cancels pending local reminders. In the local queue, so a reminder being
|
|
789
|
+
* set when a withdrawal lands is scheduled before this sweep runs.
|
|
790
|
+
*/
|
|
791
|
+
const cancelLocalWhere = (refused) => enqueue('local', async () => {
|
|
792
|
+
if ((await provider.getCapabilities()).local.supported) {
|
|
793
|
+
await cancelLocalNow(refused);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
/** The device would still show local reminders after permission stops being granted. */
|
|
797
|
+
const cancelRevokedLocal = async (capabilities, states, topics) => {
|
|
798
|
+
if (!capabilities.local.supported) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (capabilities.permission.scope === 'app') {
|
|
802
|
+
if (states.app !== undefined && revokesLocalReminders(states.app)) {
|
|
803
|
+
await cancelLocalWhere(() => true);
|
|
804
|
+
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const revoked = topics.filter((topic) => revokesLocalReminders(stateIn(states, topic)));
|
|
808
|
+
if (revoked.length > 0) {
|
|
809
|
+
await cancelLocalWhere((entry) => revoked.includes(entry.topic));
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* Cancels the pending local reminders that no longer pass, in two sweeps.
|
|
814
|
+
* The first, without the backend, takes every one whose topic no longer
|
|
815
|
+
* takes the local route: an app update removed the topic from the catalog or
|
|
816
|
+
* took away its `local` channel. The second takes the others whose
|
|
817
|
+
* permission is known and not granted and, with the backend's records,
|
|
818
|
+
* those whose topic preference refuses them, because a preference changed
|
|
819
|
+
* on another device never reaches this one as an event. The second sweep
|
|
820
|
+
* runs even when the first fails, and the first one's error is thrown at
|
|
821
|
+
* the end (with the second one's, if both fail).
|
|
822
|
+
*/
|
|
823
|
+
const reconcileLocal = () => enqueue('local', async () => {
|
|
824
|
+
const capabilities = await provider.getCapabilities();
|
|
825
|
+
if (!capabilities.local.supported || provider.cancelLocal === undefined) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const localTopicOf = (entry) => {
|
|
829
|
+
const topic = topicOf(entry.topic);
|
|
830
|
+
return topic !== undefined && routeNotificationReminder(topic, capabilities) === 'local'
|
|
831
|
+
? topic
|
|
832
|
+
: undefined;
|
|
833
|
+
};
|
|
834
|
+
let offRoute;
|
|
835
|
+
try {
|
|
836
|
+
await cancelLocalNow((entry) => localTopicOf(entry) === undefined);
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
offRoute = { error };
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
await cancelRefusedLocal(capabilities, localTopicOf);
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (offRoute === undefined) {
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
throw new AggregateError([offRoute.error, error], 'Neither sweep of the local reminders completed. First: ' + describeError(offRoute.error));
|
|
849
|
+
}
|
|
850
|
+
if (offRoute !== undefined) {
|
|
851
|
+
throw offRoute.error;
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
/**
|
|
855
|
+
* Inside the local queue: the second sweep. Reminders whose topic left the
|
|
856
|
+
* local route belong to the first sweep and are skipped, so a reminder the
|
|
857
|
+
* host cannot cancel is not tried twice.
|
|
858
|
+
*/
|
|
859
|
+
const cancelRefusedLocal = async (capabilities, localTopicOf) => {
|
|
860
|
+
const pending = (await listLocalEntries()).filter((entry) => localTopicOf(entry) !== undefined);
|
|
861
|
+
if (pending.length === 0) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const records = await readRecords();
|
|
865
|
+
const topics = [...new Set(pending.map((entry) => entry.topic))];
|
|
866
|
+
const states = resolveStates(capabilities, await provider.getPermission({ topics }), topics, records);
|
|
867
|
+
await cancelLocalNow((entry) => {
|
|
868
|
+
const topic = localTopicOf(entry);
|
|
869
|
+
if (topic === undefined) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
if (revokesLocalReminders(stateIn(states, entry.topic))) {
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
return records !== undefined && preferenceRefuses(topic, records, clock.now());
|
|
876
|
+
});
|
|
877
|
+
};
|
|
878
|
+
/** The zone the device registered, else the deployment's. */
|
|
879
|
+
const localZone = (registered) => {
|
|
880
|
+
const zone = registered?.timeZone;
|
|
881
|
+
return zone !== undefined && isValidNotificationTimeZone(zone) ? zone : deploymentZone;
|
|
882
|
+
};
|
|
883
|
+
/** Inside the local queue: the client gate steps, then the provider's schedule. */
|
|
884
|
+
const scheduleLocalReminder = async (capabilities, topic, request, player) => {
|
|
885
|
+
const scheduleLocal = provider.scheduleLocal;
|
|
886
|
+
if (scheduleLocal === undefined) {
|
|
887
|
+
return { route: 'local', status: 'unavailable' };
|
|
888
|
+
}
|
|
889
|
+
const records = await readRecords();
|
|
890
|
+
const states = resolveStates(capabilities, await provider.getPermission({ topics: [topic.id] }), [topic.id], records);
|
|
891
|
+
let registered = latestRegistration;
|
|
892
|
+
if (registered === undefined) {
|
|
893
|
+
try {
|
|
894
|
+
registered = (kept ?? await loadState()).registration?.registration;
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
// Without the stored registration the deployment's zone applies, as before any registration.
|
|
898
|
+
fail(error);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const decision = evaluateLocalNotificationReminder({
|
|
902
|
+
topic,
|
|
903
|
+
permission: stateIn(states, topic.id),
|
|
904
|
+
consent: records ?? [],
|
|
905
|
+
fireAt: request.fireAt,
|
|
906
|
+
timeZone: localZone(registered),
|
|
907
|
+
// The topic's other pending reminders, which only its daily cap reads;
|
|
908
|
+
// replacing one does not count it against itself.
|
|
909
|
+
...(topic.frequency === undefined
|
|
910
|
+
? {}
|
|
911
|
+
: {
|
|
912
|
+
pending: (await listLocalEntries()).flatMap((entry) => entry.topic === topic.id && entry.key !== request.key && entry.fireAt !== undefined
|
|
913
|
+
? [{ fireAt: providerFireAt(entry.fireAt) }]
|
|
914
|
+
: []),
|
|
915
|
+
}),
|
|
916
|
+
});
|
|
917
|
+
if (decision.status !== 'schedule') {
|
|
918
|
+
return { route: 'local', status: decision.status };
|
|
919
|
+
}
|
|
920
|
+
const locale = input.locale ?? input.defaultLocale;
|
|
921
|
+
if (!hasLocalCopy(topic, locale, input.defaultLocale)) {
|
|
922
|
+
return { route: 'local', status: 'unavailable' };
|
|
923
|
+
}
|
|
924
|
+
// Local copy is free-form content: the mapping's title and body, else the catalog copy.
|
|
925
|
+
let content;
|
|
926
|
+
try {
|
|
927
|
+
content = resolveNotificationContent({
|
|
928
|
+
topic,
|
|
929
|
+
content: 'free-form',
|
|
930
|
+
locale,
|
|
931
|
+
defaultLocale: input.defaultLocale,
|
|
932
|
+
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
937
|
+
message: error instanceof Error ? error.message : String(error),
|
|
938
|
+
cause: error,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
// One key across both routes: the server reminder with the key goes
|
|
942
|
+
// first, for the player who began the call. After an account switch the
|
|
943
|
+
// server reminder under the key is another player's, and it stays.
|
|
944
|
+
if (capabilities.remote.supported && player !== undefined && sessionPlayer() === player) {
|
|
945
|
+
await backend.cancelReminder({ key: request.key });
|
|
946
|
+
}
|
|
947
|
+
const scheduled = await scheduleLocal.call(provider, {
|
|
948
|
+
key: request.key,
|
|
949
|
+
topic: topic.id,
|
|
950
|
+
fireAt: decision.fireAt,
|
|
951
|
+
title: content.title ?? '',
|
|
952
|
+
body: content.body ?? '',
|
|
953
|
+
route: content.route,
|
|
954
|
+
});
|
|
955
|
+
if (scheduled.status === 'scheduled' || scheduled.status === 'replaced') {
|
|
956
|
+
scheduledLocal.set(request.key, {
|
|
957
|
+
key: request.key,
|
|
958
|
+
topic: topic.id,
|
|
959
|
+
fireAt: decision.fireAt,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
route: 'local',
|
|
964
|
+
status: scheduled.status === 'unsupported' ? 'unavailable' : scheduled.status,
|
|
965
|
+
};
|
|
966
|
+
};
|
|
967
|
+
/** Inside the local queue: removes a pending local reminder; whether one was pending. */
|
|
968
|
+
const removeLocalKey = async (key) => {
|
|
969
|
+
const cancelLocal = provider.cancelLocal;
|
|
970
|
+
if (cancelLocal === undefined) {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
const pending = (await listLocalEntries()).some((entry) => entry.key === key);
|
|
974
|
+
if (pending || provider.listLocal === undefined) {
|
|
975
|
+
// Without `listLocal` the runtime cannot tell, so it cancels anyway.
|
|
976
|
+
await cancelLocal.call(provider, { keys: [key] });
|
|
977
|
+
scheduledLocal.delete(key);
|
|
978
|
+
}
|
|
979
|
+
return pending;
|
|
980
|
+
};
|
|
981
|
+
/** Sends what waited for a registry check that passed (or could not run), while started. */
|
|
982
|
+
const flushAfterCheck = () => {
|
|
983
|
+
const force = forceRegistration;
|
|
984
|
+
const permission = heldPermission;
|
|
985
|
+
forceRegistration = false;
|
|
986
|
+
heldPermission = undefined;
|
|
987
|
+
if (configurationIssue !== undefined || unsubscribeProvider === undefined) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
track(queueRecords([]));
|
|
991
|
+
track(registerCurrent(force, false, permission));
|
|
992
|
+
};
|
|
993
|
+
// Events and authentication.
|
|
994
|
+
const onProviderEvent = (event) => {
|
|
995
|
+
// The runtime's own handling first, so a game listener cannot skip it.
|
|
996
|
+
if (event.type === 'endpoint-changed') {
|
|
997
|
+
const { registration } = event;
|
|
998
|
+
track(enqueue('register', async () => {
|
|
999
|
+
const { remote } = await provider.getCapabilities();
|
|
1000
|
+
const platformUser = registration.address.kind === 'platform-user';
|
|
1001
|
+
if (remote.supported
|
|
1002
|
+
&& (remote.addressing === 'platform-user') === platformUser
|
|
1003
|
+
&& remote.addressing !== 'none') {
|
|
1004
|
+
// A rotated address replaces the old one for this installation; a
|
|
1005
|
+
// platform user's changed locale or zone reaches the backend too.
|
|
1006
|
+
await sendRegistration(registration, false);
|
|
1007
|
+
}
|
|
1008
|
+
}));
|
|
1009
|
+
}
|
|
1010
|
+
if (event.type === 'permission-changed') {
|
|
1011
|
+
// The change belongs to the player authenticated now.
|
|
1012
|
+
const player = sessionPlayer();
|
|
1013
|
+
track((async () => {
|
|
1014
|
+
const capabilities = await provider.getCapabilities();
|
|
1015
|
+
const topics = Object.keys(event.snapshot.topics);
|
|
1016
|
+
const states = resolveStates(capabilities, event.snapshot, topics, await readRecords());
|
|
1017
|
+
await enqueue('record', () => writeRecords(permissionRecords(capabilities, hostObservations(capabilities, states, topics), undefined, player)));
|
|
1018
|
+
await syncRegistration(capabilities, states.app);
|
|
1019
|
+
await cancelRevokedLocal(capabilities, states, topics);
|
|
1020
|
+
})());
|
|
1021
|
+
}
|
|
1022
|
+
emit(event);
|
|
1023
|
+
};
|
|
1024
|
+
const onAuthenticated = () => {
|
|
1025
|
+
// Records observed before authentication go to this session's player, the
|
|
1026
|
+
// registration goes again (a new session always sends it), and pending
|
|
1027
|
+
// local reminders meet the player's records.
|
|
1028
|
+
track(queueRecords([]));
|
|
1029
|
+
track(registerCurrent(false, true));
|
|
1030
|
+
track(reconcileLocal());
|
|
1031
|
+
};
|
|
1032
|
+
return {
|
|
1033
|
+
async start() {
|
|
1034
|
+
unsubscribeProvider ??= provider.subscribe(onProviderEvent);
|
|
1035
|
+
unsubscribeAuthentication ??= backend.onAuthenticated(onAuthenticated);
|
|
1036
|
+
startCapabilities = await provider.getCapabilities();
|
|
1037
|
+
/** Compares with the capabilities of the latest start, whichever start began the load. */
|
|
1038
|
+
const compare = (entry) => {
|
|
1039
|
+
const capabilities = startCapabilities;
|
|
1040
|
+
configurationIssue = entry === undefined || capabilities === undefined || !capabilities.remote.supported
|
|
1041
|
+
? undefined
|
|
1042
|
+
: notificationRegistryMismatch(capabilities, entry);
|
|
1043
|
+
};
|
|
1044
|
+
const known = input.registryEntry ?? loadedRegistryEntry;
|
|
1045
|
+
compare(known);
|
|
1046
|
+
const load = input.loadRegistryEntry;
|
|
1047
|
+
// Only a provider with remote delivery is compared, so only it loads an entry.
|
|
1048
|
+
if (known === undefined
|
|
1049
|
+
&& load !== undefined
|
|
1050
|
+
&& registryEntryLoad === undefined
|
|
1051
|
+
&& startCapabilities.remote.supported) {
|
|
1052
|
+
// Neither awaited nor tracked, so a backend that is slow or never
|
|
1053
|
+
// answers holds up neither the start nor `runUntilIdle`; a failure is
|
|
1054
|
+
// still reported through `onError` and the next `runUntilIdle`.
|
|
1055
|
+
const loading = Promise.resolve().then(load).then((loaded) => {
|
|
1056
|
+
loadedRegistryEntry = loaded;
|
|
1057
|
+
compare(loaded);
|
|
1058
|
+
});
|
|
1059
|
+
registryEntryLoad = loading;
|
|
1060
|
+
loading.then(() => {
|
|
1061
|
+
registryEntryLoad = undefined;
|
|
1062
|
+
// What waited for the check goes out now, unless the check failed
|
|
1063
|
+
// or the runtime stopped meanwhile.
|
|
1064
|
+
flushAfterCheck();
|
|
1065
|
+
}, (error) => {
|
|
1066
|
+
registryEntryLoad = undefined;
|
|
1067
|
+
fail(error);
|
|
1068
|
+
// Without the entry the runtime goes on unchecked, so what waited goes out.
|
|
1069
|
+
flushAfterCheck();
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
if (configurationIssue !== undefined) {
|
|
1073
|
+
// Fail closed: nothing reaches the backend for a provider its registry
|
|
1074
|
+
// entry disagrees with; every call throws NOTIFICATIONS_CONFIGURATION_REQUIRED.
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
// Records that waited for a check that now passes.
|
|
1078
|
+
await settle(queueRecords([]));
|
|
1079
|
+
// A backend that cannot answer does not fail the start; the next read reconciles.
|
|
1080
|
+
await settle(reconcileLocal());
|
|
1081
|
+
await settle(refreshRegistration(true));
|
|
1082
|
+
},
|
|
1083
|
+
stop() {
|
|
1084
|
+
unsubscribeProvider?.();
|
|
1085
|
+
unsubscribeProvider = undefined;
|
|
1086
|
+
unsubscribeAuthentication?.();
|
|
1087
|
+
unsubscribeAuthentication = undefined;
|
|
1088
|
+
listeners.clear();
|
|
1089
|
+
buffer = [];
|
|
1090
|
+
// A later start buffers events for its first subscriber again.
|
|
1091
|
+
gameSubscribed = false;
|
|
1092
|
+
},
|
|
1093
|
+
async runUntilIdle() {
|
|
1094
|
+
while (background.size > 0) {
|
|
1095
|
+
await Promise.all([...background]);
|
|
1096
|
+
}
|
|
1097
|
+
const first = failure;
|
|
1098
|
+
failure = undefined;
|
|
1099
|
+
if (first !== undefined) {
|
|
1100
|
+
throw first.count === 1
|
|
1101
|
+
? first.error
|
|
1102
|
+
: new AggregateError([first.error], `${first.count} background failures since the last runUntilIdle; the first: `
|
|
1103
|
+
+ describeError(first.error));
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
async getPermission(input) {
|
|
1107
|
+
assertTopics(input.topics, false);
|
|
1108
|
+
// A topic listed twice is read once.
|
|
1109
|
+
const request = { topics: [...new Set(input.topics)] };
|
|
1110
|
+
const player = sessionPlayer();
|
|
1111
|
+
const capabilities = await capabilitiesOf();
|
|
1112
|
+
const snapshot = await provider.getPermission({ topics: request.topics });
|
|
1113
|
+
if (capabilities.availability === 'unsupported') {
|
|
1114
|
+
return {
|
|
1115
|
+
...snapshot,
|
|
1116
|
+
sources: Object.fromEntries(request.topics.map((topic) => [topic, 'host'])),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
let records;
|
|
1120
|
+
try {
|
|
1121
|
+
records = await readRecords();
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
// A read still answers when the backend cannot.
|
|
1125
|
+
fail(error);
|
|
1126
|
+
}
|
|
1127
|
+
const states = resolveStates(capabilities, snapshot, request.topics, records);
|
|
1128
|
+
await settle(queueRecords(permissionRecords(capabilities, hostObservations(capabilities, states, request.topics), undefined, player)));
|
|
1129
|
+
await settle(syncRegistration(capabilities, states.app));
|
|
1130
|
+
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
1131
|
+
if (records !== undefined) {
|
|
1132
|
+
await settle(reconcileLocal());
|
|
1133
|
+
}
|
|
1134
|
+
await settle(refreshRegistration(false));
|
|
1135
|
+
return {
|
|
1136
|
+
...(states.app === undefined ? {} : { app: states.app }),
|
|
1137
|
+
topics: states.topics,
|
|
1138
|
+
sources: states.sources,
|
|
1139
|
+
...(snapshot.requiredAction === undefined ? {} : { requiredAction: snapshot.requiredAction }),
|
|
1140
|
+
};
|
|
1141
|
+
},
|
|
1142
|
+
async requestPermission(input) {
|
|
1143
|
+
assertTopics(input.topics, true);
|
|
1144
|
+
// A topic listed twice is asked, and recorded, once.
|
|
1145
|
+
const request = { ...input, topics: [...new Set(input.topics)] };
|
|
1146
|
+
// The records belong to the player signed in when the prompt opened.
|
|
1147
|
+
const player = sessionPlayer();
|
|
1148
|
+
if (typeof request.gesture !== 'boolean') {
|
|
1149
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1150
|
+
message: 'requestPermission needs a boolean gesture.',
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
const capabilities = await capabilitiesOf();
|
|
1154
|
+
const every = (outcome) => Object.fromEntries(request.topics.map((topic) => [topic, outcome]));
|
|
1155
|
+
if (capabilities.availability === 'unsupported') {
|
|
1156
|
+
// Unsupported is an answer: no prompt is reached.
|
|
1157
|
+
return {
|
|
1158
|
+
snapshot: await provider.getPermission({ topics: request.topics }),
|
|
1159
|
+
outcomes: every('unavailable'),
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
if (capabilities.permission.userGesture && !request.gesture) {
|
|
1163
|
+
throw new NotificationError('NOTIFICATIONS_USER_GESTURE_REQUIRED', {
|
|
1164
|
+
message: 'This provider asks for notification permission only inside a player gesture.',
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
const before = await provider.getPermission({ topics: request.topics });
|
|
1168
|
+
if (capabilities.availability === 'action-required') {
|
|
1169
|
+
// The game resolves the action (identity.requestUpgrade on Devvit) and asks again.
|
|
1170
|
+
return {
|
|
1171
|
+
snapshot: before,
|
|
1172
|
+
outcomes: every('action-required'),
|
|
1173
|
+
requiredAction: before.requiredAction ?? 'login',
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
const scope = capabilities.permission.scope;
|
|
1177
|
+
const outcomes = new Map();
|
|
1178
|
+
const asked = [];
|
|
1179
|
+
for (const topic of request.topics) {
|
|
1180
|
+
const declared = topicOf(topic);
|
|
1181
|
+
if (declared?.mapping === undefined) {
|
|
1182
|
+
outcomes.set(topic, 'unavailable');
|
|
1183
|
+
}
|
|
1184
|
+
else if (scope === 'topic' && notificationTopicNeedsNoAgreement(declared)) {
|
|
1185
|
+
outcomes.set(topic, 'already-granted');
|
|
1186
|
+
}
|
|
1187
|
+
else if (notificationPermissionStateOf(before, topic) === 'blocked') {
|
|
1188
|
+
// Only a state the provider read from the host stops a request; a record never does.
|
|
1189
|
+
outcomes.set(topic, 'blocked');
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
asked.push(topic);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const result = asked.length === 0
|
|
1196
|
+
? undefined
|
|
1197
|
+
: await provider.requestPermission({ topics: asked });
|
|
1198
|
+
for (const topic of asked) {
|
|
1199
|
+
outcomes.set(topic, result !== undefined && Object.prototype.hasOwnProperty.call(result.outcomes, topic)
|
|
1200
|
+
? (result.outcomes[topic] ?? 'unavailable')
|
|
1201
|
+
: 'unavailable');
|
|
1202
|
+
}
|
|
1203
|
+
// The host has answered: its answer is returned whatever the backend does.
|
|
1204
|
+
let records;
|
|
1205
|
+
try {
|
|
1206
|
+
records = await readRecords();
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
fail(error);
|
|
1210
|
+
}
|
|
1211
|
+
const after = mergeSnapshots(before, result?.snapshot, asked, outcomes, scope);
|
|
1212
|
+
const states = resolveStates(capabilities, after, request.topics, records, asked.some((topic) => outcomes.get(topic) === 'denied'));
|
|
1213
|
+
const decidedTopics = asked.filter((topic) => isDecision(outcomes.get(topic)));
|
|
1214
|
+
const observations = scope === 'app'
|
|
1215
|
+
? (states.app === undefined || states.appSource !== 'host' || decidedTopics.length === 0
|
|
1216
|
+
? []
|
|
1217
|
+
: [{ state: states.app, accepted: asked.some((topic) => isAcceptance(outcomes.get(topic))) }])
|
|
1218
|
+
: decidedTopics
|
|
1219
|
+
.filter((topic) => states.sources[topic] === 'host')
|
|
1220
|
+
.map((topic) => ({
|
|
1221
|
+
topic,
|
|
1222
|
+
state: stateIn(states, topic),
|
|
1223
|
+
accepted: isAcceptance(outcomes.get(topic)),
|
|
1224
|
+
}));
|
|
1225
|
+
const pending = permissionRecords(capabilities, observations, result?.evidence, player);
|
|
1226
|
+
if (scope === 'topic') {
|
|
1227
|
+
for (const topic of asked) {
|
|
1228
|
+
// Accepting a requested topic's agreement is the player's request for it.
|
|
1229
|
+
if (isAcceptance(outcomes.get(topic)) && topicOf(topic)?.purpose === 'requested') {
|
|
1230
|
+
pending.push({
|
|
1231
|
+
mode: 'changed',
|
|
1232
|
+
player,
|
|
1233
|
+
record: { kind: 'topic-preference', topic, state: 'granted', source: 'player', at: clock.now() },
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
await settle(queueRecords(pending));
|
|
1239
|
+
await settle(syncRegistration(capabilities, states.app));
|
|
1240
|
+
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
1241
|
+
return {
|
|
1242
|
+
snapshot: {
|
|
1243
|
+
...(states.app === undefined ? {} : { app: states.app }),
|
|
1244
|
+
topics: states.topics,
|
|
1245
|
+
...(result?.snapshot.requiredAction === undefined
|
|
1246
|
+
? {}
|
|
1247
|
+
: { requiredAction: result.snapshot.requiredAction }),
|
|
1248
|
+
},
|
|
1249
|
+
outcomes: Object.fromEntries(request.topics.map((topic) => [topic, outcomes.get(topic) ?? 'unavailable'])),
|
|
1250
|
+
...(result?.requiredAction === undefined ? {} : { requiredAction: result.requiredAction }),
|
|
1251
|
+
...(result?.evidence === undefined ? {} : { evidence: result.evidence }),
|
|
1252
|
+
};
|
|
1253
|
+
},
|
|
1254
|
+
async revokePermission(input) {
|
|
1255
|
+
assertTopics(input.topics, true);
|
|
1256
|
+
const request = { topics: [...new Set(input.topics)] };
|
|
1257
|
+
const player = sessionPlayer();
|
|
1258
|
+
const capabilities = await capabilitiesOf();
|
|
1259
|
+
const revoke = provider.revokePermission;
|
|
1260
|
+
if (capabilities.permission.prompt !== 'game' || revoke === undefined) {
|
|
1261
|
+
throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
|
|
1262
|
+
message: 'Only a game-rendered consent is withdrawn in the game; others change in OS or host settings.',
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
if (capabilities.availability === 'action-required') {
|
|
1266
|
+
// A logged-out player has no consent at the host to withdraw yet.
|
|
1267
|
+
const snapshot = await provider.getPermission({ topics: request.topics });
|
|
1268
|
+
return {
|
|
1269
|
+
snapshot,
|
|
1270
|
+
outcomes: Object.fromEntries(request.topics.map((topic) => [topic, 'action-required'])),
|
|
1271
|
+
requiredAction: snapshot.requiredAction ?? 'login',
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const result = await revoke.call(provider, { topics: request.topics });
|
|
1275
|
+
// Read again: a revocation is a decision, so not opted in reads `denied` from here on.
|
|
1276
|
+
const states = resolveStates(capabilities, await provider.getPermission({ topics: request.topics }), request.topics, undefined, true);
|
|
1277
|
+
if (capabilities.permission.scope === 'app'
|
|
1278
|
+
&& !isDeviceAddressing(capabilities)
|
|
1279
|
+
&& states.app !== undefined
|
|
1280
|
+
&& isRecordableNotificationPermissionState(states.app)) {
|
|
1281
|
+
await settle(queueRecords([{
|
|
1282
|
+
mode: 'changed',
|
|
1283
|
+
player,
|
|
1284
|
+
record: { kind: 'delivery-permission', state: states.app, source: 'player', at: clock.now() },
|
|
1285
|
+
}]));
|
|
1286
|
+
}
|
|
1287
|
+
// A device endpoint stops at the host's answer too.
|
|
1288
|
+
await settle(syncRegistration(capabilities, states.app));
|
|
1289
|
+
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
1290
|
+
return {
|
|
1291
|
+
snapshot: { ...(states.app === undefined ? {} : { app: states.app }), topics: states.topics },
|
|
1292
|
+
outcomes: Object.fromEntries(request.topics.map((topic) => [
|
|
1293
|
+
topic,
|
|
1294
|
+
Object.prototype.hasOwnProperty.call(result.outcomes, topic)
|
|
1295
|
+
? (result.outcomes[topic] ?? 'denied')
|
|
1296
|
+
: 'denied',
|
|
1297
|
+
])),
|
|
1298
|
+
};
|
|
1299
|
+
},
|
|
1300
|
+
async register() {
|
|
1301
|
+
await capabilitiesOf();
|
|
1302
|
+
await registerCurrent(true, true);
|
|
1303
|
+
},
|
|
1304
|
+
async unregister() {
|
|
1305
|
+
// The player signing out, captured before any wait, and the unbind
|
|
1306
|
+
// queued at once, so an authentication that follows is never the one
|
|
1307
|
+
// unbound.
|
|
1308
|
+
const session = backend.session();
|
|
1309
|
+
const unbinding = enqueue('register', async () => {
|
|
1310
|
+
// The unbind is tried even when the stored state cannot be read; the
|
|
1311
|
+
// unbound state is then kept in memory and saved over it.
|
|
1312
|
+
// A load that never settles still holds the unbind up; the storage
|
|
1313
|
+
// port is expected to answer or fail.
|
|
1314
|
+
let loadFailure;
|
|
1315
|
+
let persisted;
|
|
1316
|
+
try {
|
|
1317
|
+
persisted = kept ?? await loadState();
|
|
1318
|
+
}
|
|
1319
|
+
catch (error) {
|
|
1320
|
+
loadFailure = { error };
|
|
1321
|
+
// A load another call started meanwhile may have set the state; the
|
|
1322
|
+
// unbound state goes on whichever object is kept, and is written
|
|
1323
|
+
// over the stored one either way.
|
|
1324
|
+
kept ??= {};
|
|
1325
|
+
persisted = kept;
|
|
1326
|
+
}
|
|
1327
|
+
persisted.unbound = true;
|
|
1328
|
+
if (session !== undefined) {
|
|
1329
|
+
persisted.unboundSession = { player: session.player, session: session.id };
|
|
1330
|
+
}
|
|
1331
|
+
const saveFailures = [];
|
|
1332
|
+
/** Saves the state; a failure is collected, and a synchronous save answers synchronously. */
|
|
1333
|
+
const save = () => {
|
|
1334
|
+
let saved;
|
|
1335
|
+
try {
|
|
1336
|
+
saved = saveState(persisted);
|
|
1337
|
+
}
|
|
1338
|
+
catch (error) {
|
|
1339
|
+
saveFailures.push(error);
|
|
1340
|
+
return undefined;
|
|
1341
|
+
}
|
|
1342
|
+
return saved?.then(undefined, (error) => {
|
|
1343
|
+
saveFailures.push(error);
|
|
1344
|
+
});
|
|
1345
|
+
};
|
|
1346
|
+
// Saved before the unbind, so a restart during it binds nothing.
|
|
1347
|
+
const before = save();
|
|
1348
|
+
if (before !== undefined) {
|
|
1349
|
+
await before;
|
|
1350
|
+
}
|
|
1351
|
+
let unbindFailure;
|
|
1352
|
+
try {
|
|
1353
|
+
if (session !== undefined && isDeviceAddressing(await provider.getCapabilities())) {
|
|
1354
|
+
await backend.unregisterEndpoint({ provider: provider.provider, player: session.player });
|
|
1355
|
+
if (persisted.registration?.session === session.id) {
|
|
1356
|
+
delete persisted.registration;
|
|
1357
|
+
const after = save();
|
|
1358
|
+
if (after !== undefined) {
|
|
1359
|
+
await after;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
unbindFailure = { error };
|
|
1366
|
+
}
|
|
1367
|
+
// Only the unbind's own error is thrown, so a caller can tell a
|
|
1368
|
+
// logout that did not reach the backend; the storage's failures reach
|
|
1369
|
+
// onError and runUntilIdle.
|
|
1370
|
+
for (const error of [...(loadFailure === undefined ? [] : [loadFailure.error]), ...saveFailures]) {
|
|
1371
|
+
fail(error);
|
|
1372
|
+
}
|
|
1373
|
+
if (unbindFailure !== undefined) {
|
|
1374
|
+
throw unbindFailure.error;
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
// The device's pending local reminders belong to the player leaving.
|
|
1378
|
+
// They are cancelled whether or not the unbind reaches the backend (an
|
|
1379
|
+
// offline logout), and either failure is thrown once both have run.
|
|
1380
|
+
const outcomes = await Promise.allSettled([unbinding, cancelLocalWhere(() => true)]);
|
|
1381
|
+
const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
|
|
1382
|
+
if (failures.length > 1) {
|
|
1383
|
+
throw new AggregateError(failures, 'unregister() could neither unbind nor cancel the local reminders.');
|
|
1384
|
+
}
|
|
1385
|
+
if (failures.length === 1) {
|
|
1386
|
+
throw failures[0];
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
async setTopicPreference(request) {
|
|
1390
|
+
if (typeof request.topic !== 'string'
|
|
1391
|
+
|| topicOf(request.topic) === undefined
|
|
1392
|
+
|| typeof request.enabled !== 'boolean') {
|
|
1393
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1394
|
+
message: 'setTopicPreference needs a declared topic and a boolean.',
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
if (!request.enabled && sessionPlayer() === undefined) {
|
|
1398
|
+
// The device stops showing the topic's reminders even before the
|
|
1399
|
+
// player signs in; the withdrawal itself needs the player.
|
|
1400
|
+
await cancelLocalWhere((entry) => entry.topic === request.topic);
|
|
1401
|
+
}
|
|
1402
|
+
const player = requirePlayer('setTopicPreference');
|
|
1403
|
+
try {
|
|
1404
|
+
await capabilitiesOf();
|
|
1405
|
+
// Never recorded for a player who is no longer signed in.
|
|
1406
|
+
assertSamePlayer('setTopicPreference', player);
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
// A withdrawal still stops this device's reminders of the topic,
|
|
1410
|
+
// which touches no backend, before the call throws.
|
|
1411
|
+
if (!request.enabled) {
|
|
1412
|
+
try {
|
|
1413
|
+
await cancelLocalWhere((entry) => entry.topic === request.topic);
|
|
1414
|
+
}
|
|
1415
|
+
catch (cancelError) {
|
|
1416
|
+
// Reported, so the call still throws its own error.
|
|
1417
|
+
fail(cancelError);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
throw error;
|
|
1421
|
+
}
|
|
1422
|
+
// The withdrawal is recorded first, so the server stops sending and no
|
|
1423
|
+
// new local reminder passes the gate even if cancelling fails.
|
|
1424
|
+
await queueRecords([{
|
|
1425
|
+
mode: 'changed',
|
|
1426
|
+
player,
|
|
1427
|
+
record: {
|
|
1428
|
+
kind: 'topic-preference',
|
|
1429
|
+
topic: request.topic,
|
|
1430
|
+
state: request.enabled ? 'granted' : 'withdrawn',
|
|
1431
|
+
source: 'player',
|
|
1432
|
+
at: clock.now(),
|
|
1433
|
+
},
|
|
1434
|
+
}]);
|
|
1435
|
+
if (!request.enabled) {
|
|
1436
|
+
await cancelLocalWhere((entry) => entry.topic === request.topic);
|
|
1437
|
+
}
|
|
1438
|
+
},
|
|
1439
|
+
async setAdvertisingConsent(request) {
|
|
1440
|
+
const states = [request.advertising, request.night];
|
|
1441
|
+
if (states.every((state) => state === undefined)
|
|
1442
|
+
|| states.some((state) => state !== undefined && state !== 'granted' && state !== 'withdrawn')
|
|
1443
|
+
|| typeof request.textVersion !== 'string'
|
|
1444
|
+
|| request.textVersion.trim() === '') {
|
|
1445
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1446
|
+
message: 'setAdvertisingConsent needs an advertising or night decision and the consent text version.',
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
const player = requirePlayer('setAdvertisingConsent');
|
|
1450
|
+
await capabilitiesOf();
|
|
1451
|
+
// Never recorded for a player who is no longer signed in.
|
|
1452
|
+
assertSamePlayer('setAdvertisingConsent', player);
|
|
1453
|
+
// Each call is a consent decision the law asks to notify, so each one is appended.
|
|
1454
|
+
await queueRecords(['advertising', 'night'].flatMap((kind) => {
|
|
1455
|
+
const state = request[kind];
|
|
1456
|
+
return state === undefined
|
|
1457
|
+
? []
|
|
1458
|
+
: [{
|
|
1459
|
+
mode: 'always',
|
|
1460
|
+
player,
|
|
1461
|
+
record: {
|
|
1462
|
+
kind,
|
|
1463
|
+
state,
|
|
1464
|
+
source: 'player',
|
|
1465
|
+
at: clock.now(),
|
|
1466
|
+
textVersion: request.textVersion,
|
|
1467
|
+
},
|
|
1468
|
+
}];
|
|
1469
|
+
}));
|
|
1470
|
+
// Advertising never takes the local route, so there is nothing to cancel.
|
|
1471
|
+
},
|
|
1472
|
+
reminders: {
|
|
1473
|
+
async set(request) {
|
|
1474
|
+
const issue = reminderInputIssue(request);
|
|
1475
|
+
if (issue !== undefined) {
|
|
1476
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1477
|
+
message: `Invalid reminder: ${issue}.`,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
// A server reminder belongs to the player signed in when the call began.
|
|
1481
|
+
const player = sessionPlayer();
|
|
1482
|
+
const capabilities = await capabilitiesOf();
|
|
1483
|
+
const topic = topicOf(request.topic);
|
|
1484
|
+
if (topic === undefined) {
|
|
1485
|
+
return { route: 'none', status: 'unavailable' };
|
|
1486
|
+
}
|
|
1487
|
+
const route = routeNotificationReminder(topic, capabilities);
|
|
1488
|
+
if (route === 'none') {
|
|
1489
|
+
return { route, status: 'unavailable' };
|
|
1490
|
+
}
|
|
1491
|
+
if (route === 'remote' && capabilities.remote.content === 'host-template' && request.templateData !== undefined) {
|
|
1492
|
+
// Template context stays on the server, as in v1.
|
|
1493
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1494
|
+
message: 'A host-template provider takes no templateData in a reminder.',
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
if (capabilities.availability === 'action-required') {
|
|
1498
|
+
return { route, status: 'action-required' };
|
|
1499
|
+
}
|
|
1500
|
+
if (route === 'local') {
|
|
1501
|
+
// In the local queue, so a withdrawal or revocation sweep that starts
|
|
1502
|
+
// while this reads consent runs after the schedule and cancels it.
|
|
1503
|
+
return enqueue('local', () => scheduleLocalReminder(capabilities, topic, request, player));
|
|
1504
|
+
}
|
|
1505
|
+
// A server reminder needs the signed-in player, checked before anything changes.
|
|
1506
|
+
requireSignedIn('reminders.set', player);
|
|
1507
|
+
// One key across both routes: the local reminder with the key goes first.
|
|
1508
|
+
if (capabilities.local.supported) {
|
|
1509
|
+
await enqueue('local', () => removeLocalKey(request.key));
|
|
1510
|
+
}
|
|
1511
|
+
assertSamePlayer('reminders.set', player);
|
|
1512
|
+
return {
|
|
1513
|
+
route,
|
|
1514
|
+
status: await backend.setReminder({
|
|
1515
|
+
key: request.key,
|
|
1516
|
+
topic: topic.id,
|
|
1517
|
+
fireAt: normalizeNotificationTimestamp(request.fireAt),
|
|
1518
|
+
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
1519
|
+
...(request.link === undefined ? {} : { link: request.link }),
|
|
1520
|
+
}),
|
|
1521
|
+
};
|
|
1522
|
+
},
|
|
1523
|
+
async cancel(request) {
|
|
1524
|
+
const issue = notificationKeyIssue(request.key);
|
|
1525
|
+
if (issue !== undefined) {
|
|
1526
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1527
|
+
message: `Invalid reminder key: ${issue}.`,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
const player = sessionPlayer();
|
|
1531
|
+
const capabilities = await capabilitiesOf();
|
|
1532
|
+
// One key across both routes: each route that has the key loses it.
|
|
1533
|
+
const local = capabilities.local.supported
|
|
1534
|
+
&& (await enqueue('local', () => removeLocalKey(request.key)));
|
|
1535
|
+
let remote = false;
|
|
1536
|
+
if (capabilities.remote.supported) {
|
|
1537
|
+
// Signed out, the local reminder is removed above and the call never
|
|
1538
|
+
// claims a cancel: a server reminder under the key may be pending
|
|
1539
|
+
// for the player who signed out. A provider waiting for a player
|
|
1540
|
+
// action answers `action-required`; any other throws
|
|
1541
|
+
// NOTIFICATIONS_TEMPORARILY_UNAVAILABLE below (RFC "Backend calls").
|
|
1542
|
+
// Retried after sign-in, it removes the server reminder too.
|
|
1543
|
+
if (capabilities.availability === 'action-required') {
|
|
1544
|
+
return { route: 'remote', status: 'action-required' };
|
|
1545
|
+
}
|
|
1546
|
+
requireSignedIn('reminders.cancel', player);
|
|
1547
|
+
remote = await backend.cancelReminder({ key: request.key });
|
|
1548
|
+
}
|
|
1549
|
+
// Both routes have the key only after a local reminder was set while
|
|
1550
|
+
// signed out; the local one is the latest set, so it answers.
|
|
1551
|
+
if (local) {
|
|
1552
|
+
return { route: 'local', status: 'cancelled' };
|
|
1553
|
+
}
|
|
1554
|
+
// Nothing is pending under the key any more.
|
|
1555
|
+
return { route: remote ? 'remote' : 'none', status: 'cancelled' };
|
|
1556
|
+
},
|
|
1557
|
+
},
|
|
1558
|
+
notices: {
|
|
1559
|
+
async list() {
|
|
1560
|
+
// The notices of the player signed in when the call began.
|
|
1561
|
+
const player = sessionPlayer();
|
|
1562
|
+
await capabilitiesOf();
|
|
1563
|
+
requireSignedIn('notices.list', player);
|
|
1564
|
+
const notices = await backend.listNotices();
|
|
1565
|
+
assertSamePlayer('notices.list', player);
|
|
1566
|
+
return notices;
|
|
1567
|
+
},
|
|
1568
|
+
async recordShown(request) {
|
|
1569
|
+
const player = sessionPlayer();
|
|
1570
|
+
// The backend gets only the name, with the record time in the canonical
|
|
1571
|
+
// form; a name that matches no listed notice records nothing.
|
|
1572
|
+
const notice = noticeIdentityOf(recordNoticeInputOf(request));
|
|
1573
|
+
await capabilitiesOf();
|
|
1574
|
+
requireSignedIn('notices.recordShown', player);
|
|
1575
|
+
await backend.recordNotice({ ...notice, channel: 'in-game' });
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
async openSettings() {
|
|
1579
|
+
const capabilities = await capabilitiesOf();
|
|
1580
|
+
const openSettings = provider.openSettings;
|
|
1581
|
+
return capabilities.permission.settings && openSettings !== undefined
|
|
1582
|
+
? openSettings.call(provider)
|
|
1583
|
+
: 'unavailable';
|
|
1584
|
+
},
|
|
1585
|
+
subscribe(listener) {
|
|
1586
|
+
listeners.add(listener);
|
|
1587
|
+
if (!gameSubscribed) {
|
|
1588
|
+
gameSubscribed = true;
|
|
1589
|
+
const drained = buffer;
|
|
1590
|
+
buffer = [];
|
|
1591
|
+
// The first subscriber receives every buffered event once, even if it throws on one.
|
|
1592
|
+
for (const error of deliverEach([listener], drained)) {
|
|
1593
|
+
fail(error);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return () => {
|
|
1597
|
+
listeners.delete(listener);
|
|
1598
|
+
};
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
function assertTopics(topics, nonEmpty) {
|
|
1603
|
+
if (!Array.isArray(topics)
|
|
1604
|
+
|| (nonEmpty && topics.length === 0)
|
|
1605
|
+
|| topics.some((topic) => typeof topic !== 'string' || topic.length === 0)) {
|
|
1606
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1607
|
+
message: nonEmpty ? 'Expected at least one topic id.' : 'Expected a list of topic ids.',
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* The snapshot after a request: the host's answer for the asked topics over
|
|
1613
|
+
* the read before it, and the answer a prompt just gave for a topic the host
|
|
1614
|
+
* cannot read back.
|
|
1615
|
+
*/
|
|
1616
|
+
function mergeSnapshots(before, after, asked, outcomes, scope) {
|
|
1617
|
+
const topics = new Map(Object.entries(before.topics));
|
|
1618
|
+
for (const [topic, state] of Object.entries(after?.topics ?? {})) {
|
|
1619
|
+
topics.set(topic, state);
|
|
1620
|
+
}
|
|
1621
|
+
let app = after?.app ?? before.app;
|
|
1622
|
+
for (const topic of asked) {
|
|
1623
|
+
const decided = outcomeState(outcomes.get(topic));
|
|
1624
|
+
if (decided === undefined) {
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
if (scope === 'app') {
|
|
1628
|
+
app = app === undefined || app === 'unknown' ? decided : app;
|
|
1629
|
+
}
|
|
1630
|
+
else if ((topics.get(topic) ?? 'unknown') === 'unknown') {
|
|
1631
|
+
topics.set(topic, decided);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (scope === 'app' && app !== undefined) {
|
|
1635
|
+
// An app-scope topic reads the app state; an `unknown` entry for it does not outlive the answer.
|
|
1636
|
+
for (const topic of asked) {
|
|
1637
|
+
if (topics.get(topic) === 'unknown') {
|
|
1638
|
+
topics.set(topic, app);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return { ...(app === undefined ? {} : { app }), topics: Object.fromEntries(topics) };
|
|
1643
|
+
}
|
|
1644
|
+
/** The state a decided outcome leaves when the host cannot be read. */
|
|
1645
|
+
function outcomeState(outcome) {
|
|
1646
|
+
switch (outcome) {
|
|
1647
|
+
case 'granted':
|
|
1648
|
+
case 'already-granted':
|
|
1649
|
+
return 'granted';
|
|
1650
|
+
case 'denied':
|
|
1651
|
+
return 'denied';
|
|
1652
|
+
case 'blocked':
|
|
1653
|
+
return 'blocked';
|
|
1654
|
+
default:
|
|
1655
|
+
return undefined;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function stateIn(states, topic) {
|
|
1659
|
+
return Object.prototype.hasOwnProperty.call(states.topics, topic)
|
|
1660
|
+
? (states.topics[topic] ?? 'unknown')
|
|
1661
|
+
: (states.app ?? 'unknown');
|
|
1662
|
+
}
|
|
1663
|
+
function isDeviceAddressing(capabilities) {
|
|
1664
|
+
return capabilities.remote.supported && capabilities.remote.addressing === 'device';
|
|
1665
|
+
}
|
|
1666
|
+
/** A known permission other than `granted`: the device no longer shows local reminders. */
|
|
1667
|
+
function revokesLocalReminders(state) {
|
|
1668
|
+
return state === 'prompt'
|
|
1669
|
+
|| state === 'prompt-with-rationale'
|
|
1670
|
+
|| state === 'denied'
|
|
1671
|
+
|| state === 'blocked';
|
|
1672
|
+
}
|
|
1673
|
+
function isAcceptance(outcome) {
|
|
1674
|
+
return outcome === 'granted' || outcome === 'already-granted';
|
|
1675
|
+
}
|
|
1676
|
+
/** An answer the player gave: never `dismissed`, `action-required`, or `unavailable`. */
|
|
1677
|
+
function isDecision(outcome) {
|
|
1678
|
+
return outcomeState(outcome) !== undefined;
|
|
1679
|
+
}
|
|
1680
|
+
/** The latest permission state recorded for a kind and scope; `withdrawn` is not a permission state. */
|
|
1681
|
+
function latestRecordState(records, kind, topic) {
|
|
1682
|
+
const state = latestNotificationConsent(records, kind, topic)?.state;
|
|
1683
|
+
return state === undefined || state === 'withdrawn' ? undefined : state;
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* A pending reminder's `fireAt` as the provider listed it, which the daily cap
|
|
1687
|
+
* reads: one that does not parse strictly is the provider breaking the
|
|
1688
|
+
* contract, not the game's input.
|
|
1689
|
+
*/
|
|
1690
|
+
function providerFireAt(fireAt) {
|
|
1691
|
+
if (!isNotificationTimestamp(fireAt)) {
|
|
1692
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
1693
|
+
message: `The provider listed a local reminder whose fireAt is not RFC 3339 with a date that exists: ${JSON.stringify(fireAt)}.`,
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return fireAt;
|
|
1697
|
+
}
|
|
1698
|
+
/** Whether the topic preference alone refuses a reminder of `topic` now. */
|
|
1699
|
+
function preferenceRefuses(topic, records, at) {
|
|
1700
|
+
const decision = evaluateLocalNotificationReminder({
|
|
1701
|
+
topic,
|
|
1702
|
+
permission: 'granted',
|
|
1703
|
+
consent: records,
|
|
1704
|
+
fireAt: at,
|
|
1705
|
+
timeZone: 'UTC',
|
|
1706
|
+
});
|
|
1707
|
+
return decision.status === 'request-missing' || decision.status === 'preference-off';
|
|
1708
|
+
}
|
|
1709
|
+
function hasLocalCopy(topic, locale, defaultLocale) {
|
|
1710
|
+
return (typeof topic.mapping?.['title'] === 'string' && typeof topic.mapping['body'] === 'string')
|
|
1711
|
+
|| findNotificationCopy(topic, locale, defaultLocale) !== undefined;
|
|
1712
|
+
}
|
|
1713
|
+
function sameJson(left, right) {
|
|
1714
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1715
|
+
}
|