@mpgd/platform 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/notifications-conformance.d.ts +31 -114
- package/dist/notifications-conformance.js +25 -1332
- package/dist/notifications-policy.d.ts +17 -0
- package/dist/notifications-policy.js +24 -0
- 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/dist/notifications.d.ts +27 -3
- package/dist/notifications.js +38 -4
- package/package.json +5 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { assertNotificationConsentRecord, assertNotificationDeployment,
|
|
1
|
+
import { assertNotificationConsentRecord, assertNotificationDeployment, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, listNotificationNotices, normalizeNotificationTimestamp, notificationDefaultDeploymentTimeZone, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, notificationTopicNeedsNoAgreement, toSeoulCalendarDate, } from './notifications.js';
|
|
2
|
+
import { createNotificationRuntime, } from './notifications-runtime.js';
|
|
3
|
+
import { bufferEvent, deliverEach, describeError, isBoundedText, noticeIdentityOf, recordNoticeInputOf, reminderInputIssue, } from './notifications-runtime-support.js';
|
|
2
4
|
import { notificationConformanceAppliesToReason, notificationConformanceDefaultLocale, } from './notifications-vectors.js';
|
|
3
5
|
/**
|
|
4
6
|
* Notifications v2 conformance runner (RFC "Conformance vectors") with the
|
|
@@ -6,7 +8,8 @@ import { notificationConformanceAppliesToReason, notificationConformanceDefaultL
|
|
|
6
8
|
* with its fixture `NotificationProvider`; a fake backend that keeps sessions,
|
|
7
9
|
* consent records, endpoints, server reminders, notices, and an idempotency
|
|
8
10
|
* ledger, and applies the pure policy gate before every provider call; and the
|
|
9
|
-
*
|
|
11
|
+
* notifications runtime the runner drives as the `game` actor, which is the
|
|
12
|
+
* production one from `@mpgd/platform/notifications-runtime`. Pure
|
|
10
13
|
* in-process code: no Phaser, DOM, network, or SDK imports. Nothing here sends
|
|
11
14
|
* a real notification or grants anything.
|
|
12
15
|
*
|
|
@@ -25,8 +28,6 @@ export const notificationConformanceRetryAfterSeconds = 60;
|
|
|
25
28
|
export const notificationConformanceLeaseSeconds = 300;
|
|
26
29
|
const secondMs = 1000;
|
|
27
30
|
const dayMs = 24 * 60 * 60 * secondMs;
|
|
28
|
-
/** A registration identical to the last acknowledged one is skipped for this long. */
|
|
29
|
-
const registrationRefreshMs = 7 * dayMs;
|
|
30
31
|
/** An endpoint not refreshed for this long counts as missing at gate step 2. */
|
|
31
32
|
const endpointStaleMs = 60 * dayMs;
|
|
32
33
|
/** A send still rate-limited this long after its first deferral ends `unavailable`. */
|
|
@@ -61,7 +62,6 @@ export function createNotificationConformanceClock(initial) {
|
|
|
61
62
|
},
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
|
-
const eventBufferLimit = 64;
|
|
65
65
|
const appKey = '\u0000app';
|
|
66
66
|
/**
|
|
67
67
|
* The fake host and its fixture provider (RFC "Vector format", fake defaults).
|
|
@@ -425,36 +425,9 @@ export function createNotificationConformanceHost(input) {
|
|
|
425
425
|
},
|
|
426
426
|
};
|
|
427
427
|
}
|
|
428
|
-
/**
|
|
429
|
-
* Buffers an event for the first listener, keeping at most
|
|
430
|
-
* `eventBufferLimit`: past it the oldest event that is not an open goes first,
|
|
431
|
-
* so a cold-start open is not lost to later events.
|
|
432
|
-
*/
|
|
433
|
-
function bufferEvent(buffer, event) {
|
|
434
|
-
buffer.push(event);
|
|
435
|
-
if (buffer.length > eventBufferLimit) {
|
|
436
|
-
const evicted = buffer.findIndex((buffered) => buffered.type !== 'opened');
|
|
437
|
-
buffer.splice(evicted === -1 ? 0 : evicted, 1);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
428
|
function isDecidedAnswer(outcome) {
|
|
441
429
|
return outcome === 'granted' || outcome === 'already-granted' || outcome === 'denied';
|
|
442
430
|
}
|
|
443
|
-
/** Calls every listener with every event, in order; returns the errors they threw. */
|
|
444
|
-
function deliverEach(listeners, events) {
|
|
445
|
-
const errors = [];
|
|
446
|
-
for (const event of events) {
|
|
447
|
-
for (const listener of listeners) {
|
|
448
|
-
try {
|
|
449
|
-
listener(event);
|
|
450
|
-
}
|
|
451
|
-
catch (error) {
|
|
452
|
-
errors.push(error);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return errors;
|
|
457
|
-
}
|
|
458
431
|
function throwFirst(errors) {
|
|
459
432
|
if (errors.length > 0) {
|
|
460
433
|
throw errors[0];
|
|
@@ -1558,50 +1531,6 @@ export function createNotificationConformanceBackend(input) {
|
|
|
1558
1531
|
},
|
|
1559
1532
|
};
|
|
1560
1533
|
}
|
|
1561
|
-
/** A well-formed `notices.recordShown` input, or `NOTIFICATIONS_INVALID_INPUT`. */
|
|
1562
|
-
function recordNoticeInputOf(input) {
|
|
1563
|
-
try {
|
|
1564
|
-
return assertRecordNotificationNoticeInput(input);
|
|
1565
|
-
}
|
|
1566
|
-
catch (error) {
|
|
1567
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
1568
|
-
message: 'A notice is a listed notice, or its kind, the kind and time of the record it reports '
|
|
1569
|
-
+ `on, and its dueBy: ${describeError(error)}`,
|
|
1570
|
-
cause: error,
|
|
1571
|
-
});
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
/**
|
|
1575
|
-
* Only the fields that name a notice, with the record time in the canonical
|
|
1576
|
-
* form; a listed notice's `record.state` and `textVersion` are left out.
|
|
1577
|
-
*/
|
|
1578
|
-
function noticeIdentityOf(notice) {
|
|
1579
|
-
return {
|
|
1580
|
-
kind: notice.kind,
|
|
1581
|
-
record: { kind: notice.record.kind, at: normalizeNotificationTimestamp(notice.record.at) },
|
|
1582
|
-
dueBy: notice.dueBy,
|
|
1583
|
-
};
|
|
1584
|
-
}
|
|
1585
|
-
/** Why a reminder's input breaks the RFC bounds, or `undefined`. */
|
|
1586
|
-
function reminderInputIssue(input) {
|
|
1587
|
-
const keyIssue = notificationKeyIssue(input.key);
|
|
1588
|
-
if (keyIssue !== undefined) {
|
|
1589
|
-
return `key: ${keyIssue}`;
|
|
1590
|
-
}
|
|
1591
|
-
if (timestampOrUndefined(input.fireAt) === undefined) {
|
|
1592
|
-
return 'fireAt: expected RFC 3339 with an explicit offset';
|
|
1593
|
-
}
|
|
1594
|
-
if (input.templateData !== undefined) {
|
|
1595
|
-
const issue = notificationTemplateDataIssue(input.templateData);
|
|
1596
|
-
if (issue !== undefined) {
|
|
1597
|
-
return `templateData: ${issue}`;
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
if (input.link !== undefined && !isBoundedText(input.link)) {
|
|
1601
|
-
return 'link: expected a non-empty string';
|
|
1602
|
-
}
|
|
1603
|
-
return undefined;
|
|
1604
|
-
}
|
|
1605
1534
|
function sendRequestOf(request, defaultPlayer) {
|
|
1606
1535
|
const issue = sendInputIssue(request);
|
|
1607
1536
|
if (issue !== undefined) {
|
|
@@ -1654,20 +1583,6 @@ function sendInputIssue(request) {
|
|
|
1654
1583
|
}
|
|
1655
1584
|
return undefined;
|
|
1656
1585
|
}
|
|
1657
|
-
function isBoundedText(value) {
|
|
1658
|
-
return typeof value === 'string' && value.length > 0 && value.length <= 16_384;
|
|
1659
|
-
}
|
|
1660
|
-
function timestampOrUndefined(value) {
|
|
1661
|
-
if (typeof value !== 'string') {
|
|
1662
|
-
return undefined;
|
|
1663
|
-
}
|
|
1664
|
-
try {
|
|
1665
|
-
return normalizeNotificationTimestamp(value);
|
|
1666
|
-
}
|
|
1667
|
-
catch {
|
|
1668
|
-
return undefined;
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
1586
|
function conflictError(key) {
|
|
1672
1587
|
return new NotificationError('NOTIFICATIONS_IDEMPOTENCY_CONFLICT', {
|
|
1673
1588
|
message: `Key ${key} is stored for another request.`,
|
|
@@ -1753,1250 +1668,31 @@ function compareText(left, right) {
|
|
|
1753
1668
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
1754
1669
|
}
|
|
1755
1670
|
/**
|
|
1756
|
-
* The
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
* - Reads return the provider snapshot. A topic the host reads `unknown`
|
|
1760
|
-
* takes the latest record, `sources` says which answered, and a
|
|
1761
|
-
* game-rendered consent that is not opted in reads `prompt` until a
|
|
1762
|
-
* decision is recorded and `denied` after it. A topic whose mapping needs no
|
|
1763
|
-
* agreement reads `granted`. A read that observes a changed decided state
|
|
1764
|
-
* records it, and every read still answers when the backend cannot.
|
|
1765
|
-
* - Reads, requests, and revocations take each topic once, however often it
|
|
1766
|
-
* is listed.
|
|
1767
|
-
* - Requests throw `NOTIFICATIONS_USER_GESTURE_REQUIRED` without a gesture on
|
|
1768
|
-
* a gesture-only provider. An unsupported provider answers `unavailable`, a
|
|
1769
|
-
* provider in `action-required` answers that with its `requiredAction`, an
|
|
1770
|
-
* undeclared or unmapped topic answers `unavailable`, a topic that needs no
|
|
1771
|
-
* agreement answers `already-granted`, and a topic the host reads `blocked`
|
|
1772
|
-
* answers `blocked`, all without reaching the host; every other topic
|
|
1773
|
-
* reaches the prompt.
|
|
1774
|
-
* - Records: decided states that differ from the latest record of their kind
|
|
1775
|
-
* and scope, never `dismissed` or `action-required` answers. Device
|
|
1776
|
-
* providers write none (the endpoint holds the permission), other app-scope
|
|
1777
|
-
* providers write `delivery-permission`, and topic-scope providers write
|
|
1778
|
-
* `host-agreement` with the host evidence, plus a `topic-preference` of
|
|
1779
|
-
* `granted` when the player accepts a `requested` topic's agreement.
|
|
1780
|
-
* Per-send acceptances are appended every time. A record belongs to the
|
|
1781
|
-
* player signed in when the call or event began: one observed before
|
|
1782
|
-
* authentication waits for it (only the latest per kind and scope), and one
|
|
1783
|
-
* observed for a player waits for that player's session. A player action
|
|
1784
|
-
* (`setTopicPreference`, `setAdvertisingConsent`) needs an authenticated
|
|
1785
|
-
* player and throws `NOTIFICATIONS_TEMPORARILY_UNAVAILABLE` without one, as
|
|
1786
|
-
* does every other backend call (a server reminder, `reminders.cancel`'s
|
|
1787
|
-
* server part, and `notices`). A backend call belongs to the player signed
|
|
1788
|
-
* in when it began and is refused when another player signed in since. A
|
|
1789
|
-
* record the backend would refuse is dropped and its error surfaces.
|
|
1790
|
-
* - Registrations: the provider's registration goes to the backend at
|
|
1791
|
-
* authentication, on `register()`, on `endpoint-changed` (a rotated device
|
|
1792
|
-
* address, or a platform user's changed locale or zone), and when a read
|
|
1793
|
-
* or request, or a revocation, changes the device's decided permission; and
|
|
1794
|
-
* at start and on reads once the last one the backend acknowledged is older
|
|
1795
|
-
* than 7 days. An identical one for the same player and session is skipped
|
|
1796
|
-
* for 7 days, except on `register()`. `unregister()` unbinds the
|
|
1797
|
-
* installation from the player signed in when it was called and cancels the
|
|
1798
|
-
* pending local reminders, even when the unbind fails, and nothing binds it
|
|
1799
|
-
* again, across restarts, until `register()` or the next authentication.
|
|
1800
|
-
* - Reminders: one key across both routes, and `cancel` removes it from each
|
|
1801
|
-
* route that has it. Local ones pass the client gate steps (permission,
|
|
1802
|
-
* then the topic preference from the backend, then quiet hours, which move
|
|
1803
|
-
* them or refuse a topic that is not deferrable, then the topic's daily
|
|
1804
|
-
* cap); server ones are checked and stored by the backend. Turning a topic off cancels its local
|
|
1805
|
-
* reminders, a known permission other than granted cancels them, and start,
|
|
1806
|
-
* authentication, and every backend read reconcile them with the catalog
|
|
1807
|
-
* (a topic that no longer takes the local route loses its reminders), the
|
|
1808
|
-
* latest preference, and permission.
|
|
1809
|
-
* - Game listeners run after the runtime's own handling of an event; a
|
|
1810
|
-
* listener that throws stops neither the others nor the runtime, and its
|
|
1811
|
-
* error surfaces from `runUntilIdle`, as do failures of background work:
|
|
1812
|
-
* the first one, or an `AggregateError` that counts them all. `stop()` and
|
|
1813
|
-
* a later `start()` buffer events for the next first subscriber again.
|
|
1671
|
+
* The production runtime over the conformance inputs: `storage` is a plain
|
|
1672
|
+
* object the runtime reads when it is created and rewrites after each
|
|
1673
|
+
* change, so a restart that passes the same object resumes from it.
|
|
1814
1674
|
*/
|
|
1815
1675
|
export function createNotificationConformanceRuntime(input) {
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
else {
|
|
1834
|
-
failure.count += 1;
|
|
1835
|
-
}
|
|
1836
|
-
};
|
|
1837
|
-
const waitingRecords = [];
|
|
1838
|
-
/** One promise chain per queue: records, registrations, and local reminder changes. */
|
|
1839
|
-
const queues = {
|
|
1840
|
-
record: Promise.resolve(),
|
|
1841
|
-
register: Promise.resolve(),
|
|
1842
|
-
local: Promise.resolve(),
|
|
1843
|
-
};
|
|
1844
|
-
/** Local reminders this runtime scheduled: the fallback for a provider without `listLocal`. */
|
|
1845
|
-
const scheduledLocal = new Map();
|
|
1846
|
-
const emit = (event) => {
|
|
1847
|
-
if (!gameSubscribed) {
|
|
1848
|
-
bufferEvent(buffer, event);
|
|
1849
|
-
return;
|
|
1850
|
-
}
|
|
1851
|
-
for (const error of deliverEach([...listeners], [event])) {
|
|
1852
|
-
fail(error);
|
|
1853
|
-
}
|
|
1854
|
-
};
|
|
1855
|
-
const track = (work) => {
|
|
1856
|
-
const tracked = work
|
|
1857
|
-
.catch((error) => {
|
|
1858
|
-
fail(error);
|
|
1859
|
-
})
|
|
1860
|
-
.finally(() => {
|
|
1861
|
-
background.delete(tracked);
|
|
1862
|
-
});
|
|
1863
|
-
background.add(tracked);
|
|
1864
|
-
};
|
|
1865
|
-
/** Awaits follow-up work of a call that has already answered; a failure surfaces from `runUntilIdle`. */
|
|
1866
|
-
const settle = async (work) => {
|
|
1867
|
-
try {
|
|
1868
|
-
await work;
|
|
1869
|
-
}
|
|
1870
|
-
catch (error) {
|
|
1871
|
-
fail(error);
|
|
1872
|
-
}
|
|
1873
|
-
};
|
|
1874
|
-
/**
|
|
1875
|
-
* Runs `work` after every earlier job of the same queue, in call order. A job
|
|
1876
|
-
* never enqueues onto its own queue and waits for it, which would deadlock.
|
|
1877
|
-
*/
|
|
1878
|
-
const enqueue = (queue, work) => {
|
|
1879
|
-
const run = queues[queue].then(work);
|
|
1880
|
-
queues[queue] = run.catch(() => undefined);
|
|
1881
|
-
return run;
|
|
1882
|
-
};
|
|
1883
|
-
const topicOf = (topic) => input.catalog.find((entry) => entry.id === topic);
|
|
1884
|
-
const capabilitiesOf = async () => {
|
|
1885
|
-
const capabilities = await provider.getCapabilities();
|
|
1886
|
-
if (configurationIssue !== undefined) {
|
|
1887
|
-
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
1888
|
-
message: `The provider disagrees with its registry entry: ${configurationIssue}.`,
|
|
1889
|
-
});
|
|
1890
|
-
}
|
|
1891
|
-
return capabilities;
|
|
1892
|
-
};
|
|
1893
|
-
const sessionPlayer = () => backend.session()?.player;
|
|
1894
|
-
// Consent records.
|
|
1895
|
-
/** The session player's records, with that player's records still waiting; `undefined` without a session. */
|
|
1896
|
-
const readRecords = async () => {
|
|
1897
|
-
const session = backend.session();
|
|
1898
|
-
if (session === undefined) {
|
|
1899
|
-
return undefined;
|
|
1900
|
-
}
|
|
1901
|
-
const records = await backend.listConsent();
|
|
1902
|
-
return [
|
|
1903
|
-
...records,
|
|
1904
|
-
...waitingRecords
|
|
1905
|
-
.filter((pending) => pending.player === undefined || pending.player === session.player)
|
|
1906
|
-
.map((pending) => pending.record),
|
|
1907
|
-
];
|
|
1908
|
-
};
|
|
1909
|
-
const addWaiting = (next) => {
|
|
1910
|
-
if (next.player === undefined && next.mode !== 'each') {
|
|
1911
|
-
// Before authentication only the latest per kind and scope is kept.
|
|
1912
|
-
for (let index = waitingRecords.length - 1; index >= 0; index -= 1) {
|
|
1913
|
-
const waiting = waitingRecords[index];
|
|
1914
|
-
if (waiting !== undefined
|
|
1915
|
-
&& waiting.player === undefined
|
|
1916
|
-
&& waiting.mode !== 'each'
|
|
1917
|
-
&& waiting.record.kind === next.record.kind
|
|
1918
|
-
&& waiting.record.topic === next.record.topic) {
|
|
1919
|
-
waitingRecords.splice(index, 1);
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
waitingRecords.push(next);
|
|
1924
|
-
};
|
|
1925
|
-
/**
|
|
1926
|
-
* Inside the record queue: writes the session player's waiting records and
|
|
1927
|
-
* then `pending`, in order. What cannot be written waits for the next write.
|
|
1928
|
-
*/
|
|
1929
|
-
const writeRecords = async (pending) => {
|
|
1930
|
-
for (const next of pending) {
|
|
1931
|
-
addWaiting(next);
|
|
1932
|
-
}
|
|
1933
|
-
const session = backend.session();
|
|
1934
|
-
if (session === undefined) {
|
|
1935
|
-
return;
|
|
1936
|
-
}
|
|
1937
|
-
const mine = waitingRecords.filter((waiting) => waiting.player === undefined || waiting.player === session.player);
|
|
1938
|
-
if (mine.length === 0) {
|
|
1939
|
-
return;
|
|
1940
|
-
}
|
|
1941
|
-
for (const waiting of mine) {
|
|
1942
|
-
waitingRecords.splice(waitingRecords.indexOf(waiting), 1);
|
|
1943
|
-
}
|
|
1944
|
-
try {
|
|
1945
|
-
const records = [...(await backend.listConsent())];
|
|
1946
|
-
while (mine.length > 0) {
|
|
1947
|
-
if (backend.session()?.id !== session.id) {
|
|
1948
|
-
// The session changed during the write: the rest waits for its player.
|
|
1949
|
-
waitingRecords.push(...mine);
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
const next = mine[0];
|
|
1953
|
-
if (next !== undefined) {
|
|
1954
|
-
const latest = latestNotificationConsent(records, next.record.kind, next.record.topic);
|
|
1955
|
-
if (next.mode !== 'changed' || latest?.state !== next.record.state) {
|
|
1956
|
-
await backend.recordConsent(next.record);
|
|
1957
|
-
records.push(next.record);
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
mine.shift();
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
catch (error) {
|
|
1964
|
-
waitingRecords.push(...mine);
|
|
1965
|
-
throw error;
|
|
1966
|
-
}
|
|
1967
|
-
};
|
|
1968
|
-
const queueRecords = (pending) => {
|
|
1969
|
-
// A record the backend would refuse (a provider's malformed evidence) is
|
|
1970
|
-
// dropped, so it never blocks the records after it; its error surfaces.
|
|
1971
|
-
const valid = pending.filter((next) => {
|
|
1972
|
-
try {
|
|
1973
|
-
assertNotificationConsentRecord(next.record);
|
|
1974
|
-
return true;
|
|
1975
|
-
}
|
|
1976
|
-
catch (error) {
|
|
1977
|
-
fail(error);
|
|
1978
|
-
return false;
|
|
1979
|
-
}
|
|
1980
|
-
});
|
|
1981
|
-
return valid.length === 0 && waitingRecords.length === 0
|
|
1982
|
-
? Promise.resolve()
|
|
1983
|
-
: enqueue('record', () => writeRecords(valid));
|
|
1984
|
-
};
|
|
1985
|
-
/**
|
|
1986
|
-
* A player action is recorded for the authenticated player, never for
|
|
1987
|
-
* whoever authenticates next.
|
|
1988
|
-
*/
|
|
1989
|
-
const requirePlayer = (operation) => {
|
|
1990
|
-
const player = sessionPlayer();
|
|
1991
|
-
if (player === undefined) {
|
|
1992
|
-
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
1993
|
-
message: `${operation} records a player action, which needs an authenticated player.`,
|
|
1994
|
-
});
|
|
1995
|
-
}
|
|
1996
|
-
return player;
|
|
1997
|
-
};
|
|
1998
|
-
/** Refuses a server call when another player signed in after the call began. */
|
|
1999
|
-
const assertSamePlayer = (operation, player) => {
|
|
2000
|
-
if (sessionPlayer() !== player) {
|
|
2001
|
-
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
2002
|
-
message: `${operation} began for another player; ask again for the player signed in now.`,
|
|
2003
|
-
});
|
|
2004
|
-
}
|
|
2005
|
-
};
|
|
2006
|
-
/**
|
|
2007
|
-
* A server call needs the player who was signed in when it began, and that
|
|
2008
|
-
* player still signed in; before sign-in it is temporarily unavailable.
|
|
2009
|
-
*/
|
|
2010
|
-
const requireSignedIn = (operation, player) => {
|
|
2011
|
-
if (player === undefined) {
|
|
2012
|
-
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
2013
|
-
message: `${operation} calls the backend, which needs an authenticated player.`,
|
|
2014
|
-
});
|
|
2015
|
-
}
|
|
2016
|
-
assertSamePlayer(operation, player);
|
|
2017
|
-
};
|
|
2018
|
-
/**
|
|
2019
|
-
* The records the decided states in `observations` call for, for `player`:
|
|
2020
|
-
* the one authenticated when the call began, or `undefined` before
|
|
2021
|
-
* authentication.
|
|
2022
|
-
*/
|
|
2023
|
-
const permissionRecords = (capabilities, observations, evidence, player) => {
|
|
2024
|
-
const pending = [];
|
|
2025
|
-
const proof = evidence === undefined ? {} : { evidence };
|
|
2026
|
-
for (const observation of observations) {
|
|
2027
|
-
const state = observation.state;
|
|
2028
|
-
if (!isRecordableNotificationPermissionState(state)) {
|
|
2029
|
-
continue;
|
|
2030
|
-
}
|
|
2031
|
-
const mode = capabilities.permission.allowance === 'per-send' && observation.accepted === true
|
|
2032
|
-
? 'each'
|
|
2033
|
-
: 'changed';
|
|
2034
|
-
if (observation.topic === undefined) {
|
|
2035
|
-
// Device providers keep the permission on the endpoint, not in records.
|
|
2036
|
-
if (capabilities.permission.scope === 'app' && !isDeviceAddressing(capabilities)) {
|
|
2037
|
-
pending.push({
|
|
2038
|
-
mode,
|
|
2039
|
-
player,
|
|
2040
|
-
record: {
|
|
2041
|
-
kind: 'delivery-permission',
|
|
2042
|
-
state,
|
|
2043
|
-
source: 'client-report',
|
|
2044
|
-
at: clock.now(),
|
|
2045
|
-
...proof,
|
|
2046
|
-
},
|
|
2047
|
-
});
|
|
2048
|
-
}
|
|
2049
|
-
continue;
|
|
2050
|
-
}
|
|
2051
|
-
const topic = topicOf(observation.topic);
|
|
2052
|
-
// Host policy, not the player, granted a topic that needs no agreement.
|
|
2053
|
-
if (capabilities.permission.scope === 'topic' && topic !== undefined && !notificationTopicNeedsNoAgreement(topic)) {
|
|
2054
|
-
pending.push({
|
|
2055
|
-
mode,
|
|
2056
|
-
player,
|
|
2057
|
-
record: {
|
|
2058
|
-
kind: 'host-agreement',
|
|
2059
|
-
topic: observation.topic,
|
|
2060
|
-
state,
|
|
2061
|
-
source: 'client-report',
|
|
2062
|
-
at: clock.now(),
|
|
2063
|
-
...proof,
|
|
2064
|
-
},
|
|
2065
|
-
});
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
return pending;
|
|
2069
|
-
};
|
|
2070
|
-
/**
|
|
2071
|
-
* The states a snapshot reports after the runtime's rules: a game-rendered
|
|
2072
|
-
* consent not opted in reads `denied` once a decision is recorded (or made
|
|
2073
|
-
* now), a topic that needs no agreement reads `granted`, and an `unknown`
|
|
2074
|
-
* host read takes the latest record.
|
|
2075
|
-
*/
|
|
2076
|
-
const resolveStates = (capabilities, snapshot, topics, records, decidedNow = false) => {
|
|
2077
|
-
const scope = capabilities.permission.scope;
|
|
2078
|
-
const kind = notificationPermissionConsentKind(scope);
|
|
2079
|
-
const decided = decidedNow
|
|
2080
|
-
|| (records !== undefined && latestNotificationConsent(records, 'delivery-permission') !== undefined);
|
|
2081
|
-
const resolve = (host, topic) => {
|
|
2082
|
-
const declared = topic === undefined ? undefined : topicOf(topic);
|
|
2083
|
-
if (scope === 'topic' && declared !== undefined && notificationTopicNeedsNoAgreement(declared)) {
|
|
2084
|
-
return { state: 'granted', source: 'host' };
|
|
2085
|
-
}
|
|
2086
|
-
const state = capabilities.permission.prompt === 'game' && host === 'prompt' && decided
|
|
2087
|
-
? 'denied'
|
|
2088
|
-
: host;
|
|
2089
|
-
const recorded = state === 'unknown' && records !== undefined
|
|
2090
|
-
? latestRecordState(records, kind, scope === 'app' ? undefined : topic)
|
|
2091
|
-
: undefined;
|
|
2092
|
-
return recorded === undefined
|
|
2093
|
-
? { state, source: 'host' }
|
|
2094
|
-
: { state: recorded, source: 'record' };
|
|
2095
|
-
};
|
|
2096
|
-
const app = scope === 'app' && snapshot.app !== undefined
|
|
2097
|
-
? resolve(snapshot.app, undefined)
|
|
2098
|
-
: undefined;
|
|
2099
|
-
const resolved = topics.map((topic) => [topic, resolve(notificationPermissionStateOf(snapshot, topic), topic)]);
|
|
2100
|
-
return {
|
|
2101
|
-
...(app === undefined ? {} : { app: app.state, appSource: app.source }),
|
|
2102
|
-
topics: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.state])),
|
|
2103
|
-
sources: Object.fromEntries(resolved.map(([topic, entry]) => [topic, entry.source])),
|
|
2104
|
-
};
|
|
2105
|
-
};
|
|
2106
|
-
/** The decided states the host itself reported, one per scope. */
|
|
2107
|
-
const hostObservations = (capabilities, states, topics) => {
|
|
2108
|
-
if (capabilities.permission.scope === 'app') {
|
|
2109
|
-
return states.app === undefined || states.appSource !== 'host' ? [] : [{ state: states.app }];
|
|
2110
|
-
}
|
|
2111
|
-
return topics
|
|
2112
|
-
.filter((topic) => states.sources[topic] === 'host')
|
|
2113
|
-
.map((topic) => ({ topic, state: stateIn(states, topic) }));
|
|
2114
|
-
};
|
|
2115
|
-
// Registrations.
|
|
2116
|
-
/**
|
|
2117
|
-
* Inside the register queue: sends `registration` for the session unless
|
|
2118
|
-
* the backend acknowledged the same one for this player and session within
|
|
2119
|
-
* 7 days. One made before authentication is sent at authentication, and
|
|
2120
|
-
* nothing binds after `unregister()` until `register()` or the next
|
|
2121
|
-
* authentication, across restarts.
|
|
2122
|
-
*/
|
|
2123
|
-
const sendRegistration = async (registration, always) => {
|
|
2124
|
-
latestRegistration = registration;
|
|
2125
|
-
const session = backend.session();
|
|
2126
|
-
if (session === undefined || storage.unbound === true) {
|
|
2127
|
-
return;
|
|
2128
|
-
}
|
|
2129
|
-
const last = storage.registration;
|
|
2130
|
-
if (!always
|
|
2131
|
-
&& last !== undefined
|
|
2132
|
-
&& last.player === session.player
|
|
2133
|
-
&& last.session === session.id
|
|
2134
|
-
&& sameJson(last.registration, registration)
|
|
2135
|
-
&& Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs) {
|
|
2136
|
-
return;
|
|
2137
|
-
}
|
|
2138
|
-
await backend.registerEndpoint(registration);
|
|
2139
|
-
storage.registration = {
|
|
2140
|
-
registration,
|
|
2141
|
-
player: session.player,
|
|
2142
|
-
session: session.id,
|
|
2143
|
-
at: clock.now(),
|
|
2144
|
-
};
|
|
2145
|
-
};
|
|
2146
|
-
/**
|
|
2147
|
-
* Registers the provider's current address or platform identity; `rebind`
|
|
2148
|
-
* ends an `unregister()`. A `permission` the runtime decided (a
|
|
2149
|
-
* game-rendered consent reads `denied` once withdrawn) replaces the one a
|
|
2150
|
-
* device address carries.
|
|
2151
|
-
*/
|
|
2152
|
-
const registerCurrent = (always, rebind = false, permission) => enqueue('register', async () => {
|
|
2153
|
-
if (rebind) {
|
|
2154
|
-
delete storage.unbound;
|
|
2155
|
-
}
|
|
2156
|
-
const capabilities = await provider.getCapabilities();
|
|
2157
|
-
const register = provider.register;
|
|
2158
|
-
if (!capabilities.remote.supported || capabilities.remote.addressing === 'none' || register === undefined) {
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
const registration = (await register.call(provider)) ?? latestRegistration;
|
|
2162
|
-
if (registration !== undefined) {
|
|
2163
|
-
const { address } = registration;
|
|
2164
|
-
await sendRegistration(permission === undefined || address.kind === 'platform-user' || address.permission === permission
|
|
2165
|
-
? registration
|
|
2166
|
-
: { ...registration, address: { ...address, permission } }, always);
|
|
2167
|
-
}
|
|
2168
|
-
});
|
|
2169
|
-
/**
|
|
2170
|
-
* Re-sends the registration once the one the backend last acknowledged for
|
|
2171
|
-
* this player and session is older than 7 days, so the endpoint of a device
|
|
2172
|
-
* in use never goes stale. Runs at start and on reads.
|
|
2173
|
-
*/
|
|
2174
|
-
const refreshRegistration = async () => {
|
|
2175
|
-
const session = backend.session();
|
|
2176
|
-
const last = storage.registration;
|
|
2177
|
-
if (session === undefined
|
|
2178
|
-
|| storage.unbound === true
|
|
2179
|
-
|| last === undefined
|
|
2180
|
-
|| last.player !== session.player
|
|
2181
|
-
|| last.session !== session.id
|
|
2182
|
-
|| Date.parse(clock.now()) - Date.parse(last.at) < registrationRefreshMs
|
|
2183
|
-
|| !isDeviceAddressing(await provider.getCapabilities())) {
|
|
2184
|
-
return;
|
|
2185
|
-
}
|
|
2186
|
-
await registerCurrent(false);
|
|
2187
|
-
};
|
|
2188
|
-
/** Re-registers when the device's decided permission differs from the latest registration's. */
|
|
2189
|
-
const syncRegistration = async (capabilities, state) => {
|
|
2190
|
-
if (!isDeviceAddressing(capabilities)
|
|
2191
|
-
|| state === undefined
|
|
2192
|
-
|| !isRecordableNotificationPermissionState(state)) {
|
|
2193
|
-
return;
|
|
2194
|
-
}
|
|
2195
|
-
const address = (latestRegistration ?? storage.registration?.registration)?.address;
|
|
2196
|
-
if (address !== undefined && address.kind !== 'platform-user' && address.permission === state) {
|
|
2197
|
-
return;
|
|
2198
|
-
}
|
|
2199
|
-
await registerCurrent(false, false, state);
|
|
2200
|
-
};
|
|
2201
|
-
// Local reminders.
|
|
2202
|
-
const listLocalEntries = async () => provider.listLocal === undefined ? [...scheduledLocal.values()] : provider.listLocal();
|
|
2203
|
-
/** Inside the local queue: cancels the pending local reminders `refused` picks. */
|
|
2204
|
-
const cancelLocalNow = async (refused) => {
|
|
2205
|
-
const cancelLocal = provider.cancelLocal;
|
|
2206
|
-
if (cancelLocal === undefined) {
|
|
2207
|
-
return;
|
|
2208
|
-
}
|
|
2209
|
-
const keys = (await listLocalEntries()).filter(refused).map((entry) => entry.key);
|
|
2210
|
-
if (keys.length > 0) {
|
|
2211
|
-
await cancelLocal.call(provider, { keys });
|
|
2212
|
-
for (const key of keys) {
|
|
2213
|
-
scheduledLocal.delete(key);
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
};
|
|
2217
|
-
/**
|
|
2218
|
-
* Cancels pending local reminders. In the local queue, so a reminder being
|
|
2219
|
-
* set when a withdrawal lands is scheduled before this sweep runs.
|
|
2220
|
-
*/
|
|
2221
|
-
const cancelLocalWhere = (refused) => enqueue('local', async () => {
|
|
2222
|
-
if ((await provider.getCapabilities()).local.supported) {
|
|
2223
|
-
await cancelLocalNow(refused);
|
|
2224
|
-
}
|
|
2225
|
-
});
|
|
2226
|
-
/** The device would still show local reminders after permission stops being granted. */
|
|
2227
|
-
const cancelRevokedLocal = async (capabilities, states, topics) => {
|
|
2228
|
-
if (!capabilities.local.supported) {
|
|
2229
|
-
return;
|
|
2230
|
-
}
|
|
2231
|
-
if (capabilities.permission.scope === 'app') {
|
|
2232
|
-
if (states.app !== undefined && revokesLocalReminders(states.app)) {
|
|
2233
|
-
await cancelLocalWhere(() => true);
|
|
2234
|
-
}
|
|
2235
|
-
return;
|
|
2236
|
-
}
|
|
2237
|
-
const revoked = topics.filter((topic) => revokesLocalReminders(stateIn(states, topic)));
|
|
2238
|
-
if (revoked.length > 0) {
|
|
2239
|
-
await cancelLocalWhere((entry) => revoked.includes(entry.topic));
|
|
2240
|
-
}
|
|
2241
|
-
};
|
|
2242
|
-
/**
|
|
2243
|
-
* Cancels the pending local reminders that no longer pass, in two sweeps.
|
|
2244
|
-
* The first, without the backend, takes every one whose topic no longer
|
|
2245
|
-
* takes the local route: an app update removed the topic from the catalog or
|
|
2246
|
-
* took away its `local` channel. The second takes the others whose
|
|
2247
|
-
* permission is known and not granted and, with the backend's records,
|
|
2248
|
-
* those whose topic preference refuses them, because a preference changed
|
|
2249
|
-
* on another device never reaches this one as an event. The second sweep
|
|
2250
|
-
* runs even when the first fails, and the first one's error is thrown at
|
|
2251
|
-
* the end (with the second one's, if both fail).
|
|
2252
|
-
*/
|
|
2253
|
-
const reconcileLocal = () => enqueue('local', async () => {
|
|
2254
|
-
const capabilities = await provider.getCapabilities();
|
|
2255
|
-
if (!capabilities.local.supported || provider.cancelLocal === undefined) {
|
|
2256
|
-
return;
|
|
2257
|
-
}
|
|
2258
|
-
const localTopicOf = (entry) => {
|
|
2259
|
-
const topic = topicOf(entry.topic);
|
|
2260
|
-
return topic !== undefined && routeNotificationReminder(topic, capabilities) === 'local'
|
|
2261
|
-
? topic
|
|
2262
|
-
: undefined;
|
|
2263
|
-
};
|
|
2264
|
-
let offRoute;
|
|
2265
|
-
try {
|
|
2266
|
-
await cancelLocalNow((entry) => localTopicOf(entry) === undefined);
|
|
2267
|
-
}
|
|
2268
|
-
catch (error) {
|
|
2269
|
-
offRoute = { error };
|
|
2270
|
-
}
|
|
2271
|
-
try {
|
|
2272
|
-
await cancelRefusedLocal(capabilities, localTopicOf);
|
|
2273
|
-
}
|
|
2274
|
-
catch (error) {
|
|
2275
|
-
if (offRoute === undefined) {
|
|
2276
|
-
throw error;
|
|
2277
|
-
}
|
|
2278
|
-
throw new AggregateError([offRoute.error, error], 'Neither sweep of the local reminders completed. First: ' + describeError(offRoute.error));
|
|
2279
|
-
}
|
|
2280
|
-
if (offRoute !== undefined) {
|
|
2281
|
-
throw offRoute.error;
|
|
2282
|
-
}
|
|
2283
|
-
});
|
|
2284
|
-
/**
|
|
2285
|
-
* Inside the local queue: the second sweep. Reminders whose topic left the
|
|
2286
|
-
* local route belong to the first sweep and are skipped, so a reminder the
|
|
2287
|
-
* host cannot cancel is not tried twice.
|
|
2288
|
-
*/
|
|
2289
|
-
const cancelRefusedLocal = async (capabilities, localTopicOf) => {
|
|
2290
|
-
const pending = (await listLocalEntries()).filter((entry) => localTopicOf(entry) !== undefined);
|
|
2291
|
-
if (pending.length === 0) {
|
|
2292
|
-
return;
|
|
2293
|
-
}
|
|
2294
|
-
const records = await readRecords();
|
|
2295
|
-
const topics = [...new Set(pending.map((entry) => entry.topic))];
|
|
2296
|
-
const states = resolveStates(capabilities, await provider.getPermission({ topics }), topics, records);
|
|
2297
|
-
await cancelLocalNow((entry) => {
|
|
2298
|
-
const topic = localTopicOf(entry);
|
|
2299
|
-
if (topic === undefined) {
|
|
2300
|
-
return false;
|
|
2301
|
-
}
|
|
2302
|
-
if (revokesLocalReminders(stateIn(states, entry.topic))) {
|
|
2303
|
-
return true;
|
|
2304
|
-
}
|
|
2305
|
-
return records !== undefined && preferenceRefuses(topic, records, clock.now());
|
|
2306
|
-
});
|
|
2307
|
-
};
|
|
2308
|
-
const localZone = () => {
|
|
2309
|
-
const zone = (latestRegistration ?? storage.registration?.registration)?.timeZone;
|
|
2310
|
-
return zone !== undefined && isValidNotificationTimeZone(zone) ? zone : input.timeZone;
|
|
2311
|
-
};
|
|
2312
|
-
/** Inside the local queue: the client gate steps, then the provider's schedule. */
|
|
2313
|
-
const scheduleLocalReminder = async (capabilities, topic, request, player) => {
|
|
2314
|
-
const scheduleLocal = provider.scheduleLocal;
|
|
2315
|
-
if (scheduleLocal === undefined) {
|
|
2316
|
-
return { route: 'local', status: 'unavailable' };
|
|
2317
|
-
}
|
|
2318
|
-
const records = await readRecords();
|
|
2319
|
-
const states = resolveStates(capabilities, await provider.getPermission({ topics: [topic.id] }), [topic.id], records);
|
|
2320
|
-
const decision = evaluateLocalNotificationReminder({
|
|
2321
|
-
topic,
|
|
2322
|
-
permission: stateIn(states, topic.id),
|
|
2323
|
-
consent: records ?? [],
|
|
2324
|
-
fireAt: request.fireAt,
|
|
2325
|
-
timeZone: localZone(),
|
|
2326
|
-
// The topic's other pending reminders, which only its daily cap reads;
|
|
2327
|
-
// replacing one does not count it against itself.
|
|
2328
|
-
...(topic.frequency === undefined
|
|
2329
|
-
? {}
|
|
2330
|
-
: {
|
|
2331
|
-
pending: (await listLocalEntries()).flatMap((entry) => entry.topic === topic.id && entry.key !== request.key && entry.fireAt !== undefined
|
|
2332
|
-
? [{ fireAt: providerFireAt(entry.fireAt) }]
|
|
2333
|
-
: []),
|
|
2334
|
-
}),
|
|
2335
|
-
});
|
|
2336
|
-
if (decision.status !== 'schedule') {
|
|
2337
|
-
return { route: 'local', status: decision.status };
|
|
2338
|
-
}
|
|
2339
|
-
const locale = input.locale ?? input.defaultLocale;
|
|
2340
|
-
if (!hasLocalCopy(topic, locale, input.defaultLocale)) {
|
|
2341
|
-
return { route: 'local', status: 'unavailable' };
|
|
2342
|
-
}
|
|
2343
|
-
// Local copy is free-form content: the mapping's title and body, else the catalog copy.
|
|
2344
|
-
let content;
|
|
2345
|
-
try {
|
|
2346
|
-
content = resolveNotificationContent({
|
|
2347
|
-
topic,
|
|
2348
|
-
content: 'free-form',
|
|
2349
|
-
locale,
|
|
2350
|
-
defaultLocale: input.defaultLocale,
|
|
2351
|
-
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
2352
|
-
});
|
|
2353
|
-
}
|
|
2354
|
-
catch (error) {
|
|
2355
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2356
|
-
message: error instanceof Error ? error.message : String(error),
|
|
2357
|
-
cause: error,
|
|
2358
|
-
});
|
|
2359
|
-
}
|
|
2360
|
-
// One key across both routes: the server reminder with the key goes
|
|
2361
|
-
// first, for the player who began the call. After an account switch the
|
|
2362
|
-
// server reminder under the key is another player's, and it stays.
|
|
2363
|
-
if (capabilities.remote.supported && player !== undefined && sessionPlayer() === player) {
|
|
2364
|
-
await backend.cancelReminder({ key: request.key });
|
|
2365
|
-
}
|
|
2366
|
-
const scheduled = await scheduleLocal.call(provider, {
|
|
2367
|
-
key: request.key,
|
|
2368
|
-
topic: topic.id,
|
|
2369
|
-
fireAt: decision.fireAt,
|
|
2370
|
-
title: content.title ?? '',
|
|
2371
|
-
body: content.body ?? '',
|
|
2372
|
-
route: content.route,
|
|
2373
|
-
});
|
|
2374
|
-
if (scheduled.status === 'scheduled' || scheduled.status === 'replaced') {
|
|
2375
|
-
scheduledLocal.set(request.key, {
|
|
2376
|
-
key: request.key,
|
|
2377
|
-
topic: topic.id,
|
|
2378
|
-
fireAt: decision.fireAt,
|
|
2379
|
-
});
|
|
2380
|
-
}
|
|
2381
|
-
return {
|
|
2382
|
-
route: 'local',
|
|
2383
|
-
status: scheduled.status === 'unsupported' ? 'unavailable' : scheduled.status,
|
|
2384
|
-
};
|
|
2385
|
-
};
|
|
2386
|
-
/** Inside the local queue: removes a pending local reminder; whether one was pending. */
|
|
2387
|
-
const removeLocalKey = async (key) => {
|
|
2388
|
-
const cancelLocal = provider.cancelLocal;
|
|
2389
|
-
if (cancelLocal === undefined) {
|
|
2390
|
-
return false;
|
|
2391
|
-
}
|
|
2392
|
-
const pending = (await listLocalEntries()).some((entry) => entry.key === key);
|
|
2393
|
-
if (pending || provider.listLocal === undefined) {
|
|
2394
|
-
// Without `listLocal` the runtime cannot tell, so it cancels anyway.
|
|
2395
|
-
await cancelLocal.call(provider, { keys: [key] });
|
|
2396
|
-
scheduledLocal.delete(key);
|
|
2397
|
-
}
|
|
2398
|
-
return pending;
|
|
2399
|
-
};
|
|
2400
|
-
// Events and authentication.
|
|
2401
|
-
const onProviderEvent = (event) => {
|
|
2402
|
-
// The runtime's own handling first, so a game listener cannot skip it.
|
|
2403
|
-
if (event.type === 'endpoint-changed') {
|
|
2404
|
-
const { registration } = event;
|
|
2405
|
-
track(enqueue('register', async () => {
|
|
2406
|
-
const { remote } = await provider.getCapabilities();
|
|
2407
|
-
const platformUser = registration.address.kind === 'platform-user';
|
|
2408
|
-
if (remote.supported
|
|
2409
|
-
&& (remote.addressing === 'platform-user') === platformUser
|
|
2410
|
-
&& remote.addressing !== 'none') {
|
|
2411
|
-
// A rotated address replaces the old one for this installation; a
|
|
2412
|
-
// platform user's changed locale or zone reaches the backend too.
|
|
2413
|
-
await sendRegistration(registration, false);
|
|
2414
|
-
}
|
|
2415
|
-
}));
|
|
2416
|
-
}
|
|
2417
|
-
if (event.type === 'permission-changed') {
|
|
2418
|
-
// The change belongs to the player authenticated now.
|
|
2419
|
-
const player = sessionPlayer();
|
|
2420
|
-
track((async () => {
|
|
2421
|
-
const capabilities = await provider.getCapabilities();
|
|
2422
|
-
const topics = Object.keys(event.snapshot.topics);
|
|
2423
|
-
const states = resolveStates(capabilities, event.snapshot, topics, await readRecords());
|
|
2424
|
-
await enqueue('record', () => writeRecords(permissionRecords(capabilities, hostObservations(capabilities, states, topics), undefined, player)));
|
|
2425
|
-
await syncRegistration(capabilities, states.app);
|
|
2426
|
-
await cancelRevokedLocal(capabilities, states, topics);
|
|
2427
|
-
})());
|
|
2428
|
-
}
|
|
2429
|
-
emit(event);
|
|
2430
|
-
};
|
|
2431
|
-
const onAuthenticated = () => {
|
|
2432
|
-
// Records observed before authentication go to this session's player, the
|
|
2433
|
-
// registration goes again (a new session always sends it), and pending
|
|
2434
|
-
// local reminders meet the player's records.
|
|
2435
|
-
track(queueRecords([]));
|
|
2436
|
-
track(registerCurrent(false, true));
|
|
2437
|
-
track(reconcileLocal());
|
|
2438
|
-
};
|
|
2439
|
-
return {
|
|
2440
|
-
async start() {
|
|
2441
|
-
unsubscribeProvider ??= provider.subscribe(onProviderEvent);
|
|
2442
|
-
unsubscribeAuthentication ??= backend.onAuthenticated(onAuthenticated);
|
|
2443
|
-
const capabilities = await provider.getCapabilities();
|
|
2444
|
-
configurationIssue = input.registryEntry === undefined || !capabilities.remote.supported
|
|
2445
|
-
? undefined
|
|
2446
|
-
: notificationRegistryMismatch(capabilities, input.registryEntry);
|
|
2447
|
-
// A backend that cannot answer does not fail the start; the next read reconciles.
|
|
2448
|
-
await settle(reconcileLocal());
|
|
2449
|
-
await settle(refreshRegistration());
|
|
2450
|
-
},
|
|
2451
|
-
stop() {
|
|
2452
|
-
unsubscribeProvider?.();
|
|
2453
|
-
unsubscribeProvider = undefined;
|
|
2454
|
-
unsubscribeAuthentication?.();
|
|
2455
|
-
unsubscribeAuthentication = undefined;
|
|
2456
|
-
listeners.clear();
|
|
2457
|
-
buffer = [];
|
|
2458
|
-
// A later start buffers events for its first subscriber again.
|
|
2459
|
-
gameSubscribed = false;
|
|
2460
|
-
},
|
|
2461
|
-
async runUntilIdle() {
|
|
2462
|
-
while (background.size > 0) {
|
|
2463
|
-
await Promise.all([...background]);
|
|
2464
|
-
}
|
|
2465
|
-
const first = failure;
|
|
2466
|
-
failure = undefined;
|
|
2467
|
-
if (first !== undefined) {
|
|
2468
|
-
throw first.count === 1
|
|
2469
|
-
? first.error
|
|
2470
|
-
: new AggregateError([first.error], `${first.count} background failures since the last runUntilIdle; the first: `
|
|
2471
|
-
+ describeError(first.error));
|
|
2472
|
-
}
|
|
2473
|
-
},
|
|
2474
|
-
async getPermission(input) {
|
|
2475
|
-
assertTopics(input.topics, false);
|
|
2476
|
-
// A topic listed twice is read once.
|
|
2477
|
-
const request = { topics: [...new Set(input.topics)] };
|
|
2478
|
-
const player = sessionPlayer();
|
|
2479
|
-
const capabilities = await capabilitiesOf();
|
|
2480
|
-
const snapshot = await provider.getPermission({ topics: request.topics });
|
|
2481
|
-
if (capabilities.availability === 'unsupported') {
|
|
2482
|
-
return {
|
|
2483
|
-
...snapshot,
|
|
2484
|
-
sources: Object.fromEntries(request.topics.map((topic) => [topic, 'host'])),
|
|
2485
|
-
};
|
|
2486
|
-
}
|
|
2487
|
-
let records;
|
|
2488
|
-
try {
|
|
2489
|
-
records = await readRecords();
|
|
2490
|
-
}
|
|
2491
|
-
catch (error) {
|
|
2492
|
-
// A read still answers when the backend cannot.
|
|
2493
|
-
fail(error);
|
|
2494
|
-
}
|
|
2495
|
-
const states = resolveStates(capabilities, snapshot, request.topics, records);
|
|
2496
|
-
await settle(queueRecords(permissionRecords(capabilities, hostObservations(capabilities, states, request.topics), undefined, player)));
|
|
2497
|
-
await settle(syncRegistration(capabilities, states.app));
|
|
2498
|
-
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
2499
|
-
if (records !== undefined) {
|
|
2500
|
-
await settle(reconcileLocal());
|
|
2501
|
-
}
|
|
2502
|
-
await settle(refreshRegistration());
|
|
2503
|
-
return {
|
|
2504
|
-
...(states.app === undefined ? {} : { app: states.app }),
|
|
2505
|
-
topics: states.topics,
|
|
2506
|
-
sources: states.sources,
|
|
2507
|
-
...(snapshot.requiredAction === undefined ? {} : { requiredAction: snapshot.requiredAction }),
|
|
2508
|
-
};
|
|
2509
|
-
},
|
|
2510
|
-
async requestPermission(input) {
|
|
2511
|
-
assertTopics(input.topics, true);
|
|
2512
|
-
// A topic listed twice is asked, and recorded, once.
|
|
2513
|
-
const request = { ...input, topics: [...new Set(input.topics)] };
|
|
2514
|
-
// The records belong to the player signed in when the prompt opened.
|
|
2515
|
-
const player = sessionPlayer();
|
|
2516
|
-
if (typeof request.gesture !== 'boolean') {
|
|
2517
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2518
|
-
message: 'requestPermission needs a boolean gesture.',
|
|
2519
|
-
});
|
|
2520
|
-
}
|
|
2521
|
-
const capabilities = await capabilitiesOf();
|
|
2522
|
-
const every = (outcome) => Object.fromEntries(request.topics.map((topic) => [topic, outcome]));
|
|
2523
|
-
if (capabilities.availability === 'unsupported') {
|
|
2524
|
-
// Unsupported is an answer: no prompt is reached.
|
|
2525
|
-
return {
|
|
2526
|
-
snapshot: await provider.getPermission({ topics: request.topics }),
|
|
2527
|
-
outcomes: every('unavailable'),
|
|
2528
|
-
};
|
|
2529
|
-
}
|
|
2530
|
-
if (capabilities.permission.userGesture && !request.gesture) {
|
|
2531
|
-
throw new NotificationError('NOTIFICATIONS_USER_GESTURE_REQUIRED', {
|
|
2532
|
-
message: 'This provider asks for notification permission only inside a player gesture.',
|
|
2533
|
-
});
|
|
2534
|
-
}
|
|
2535
|
-
const before = await provider.getPermission({ topics: request.topics });
|
|
2536
|
-
if (capabilities.availability === 'action-required') {
|
|
2537
|
-
// The game resolves the action (identity.requestUpgrade on Devvit) and asks again.
|
|
2538
|
-
return {
|
|
2539
|
-
snapshot: before,
|
|
2540
|
-
outcomes: every('action-required'),
|
|
2541
|
-
requiredAction: before.requiredAction ?? 'login',
|
|
2542
|
-
};
|
|
2543
|
-
}
|
|
2544
|
-
const scope = capabilities.permission.scope;
|
|
2545
|
-
const outcomes = new Map();
|
|
2546
|
-
const asked = [];
|
|
2547
|
-
for (const topic of request.topics) {
|
|
2548
|
-
const declared = topicOf(topic);
|
|
2549
|
-
if (declared?.mapping === undefined) {
|
|
2550
|
-
outcomes.set(topic, 'unavailable');
|
|
2551
|
-
}
|
|
2552
|
-
else if (scope === 'topic' && notificationTopicNeedsNoAgreement(declared)) {
|
|
2553
|
-
outcomes.set(topic, 'already-granted');
|
|
2554
|
-
}
|
|
2555
|
-
else if (notificationPermissionStateOf(before, topic) === 'blocked') {
|
|
2556
|
-
// Only a state the provider read from the host stops a request; a record never does.
|
|
2557
|
-
outcomes.set(topic, 'blocked');
|
|
2558
|
-
}
|
|
2559
|
-
else {
|
|
2560
|
-
asked.push(topic);
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
const result = asked.length === 0
|
|
2564
|
-
? undefined
|
|
2565
|
-
: await provider.requestPermission({ topics: asked });
|
|
2566
|
-
for (const topic of asked) {
|
|
2567
|
-
outcomes.set(topic, result !== undefined && Object.prototype.hasOwnProperty.call(result.outcomes, topic)
|
|
2568
|
-
? (result.outcomes[topic] ?? 'unavailable')
|
|
2569
|
-
: 'unavailable');
|
|
2570
|
-
}
|
|
2571
|
-
// The host has answered: its answer is returned whatever the backend does.
|
|
2572
|
-
let records;
|
|
2573
|
-
try {
|
|
2574
|
-
records = await readRecords();
|
|
2575
|
-
}
|
|
2576
|
-
catch (error) {
|
|
2577
|
-
fail(error);
|
|
2578
|
-
}
|
|
2579
|
-
const after = mergeSnapshots(before, result?.snapshot, asked, outcomes, scope);
|
|
2580
|
-
const states = resolveStates(capabilities, after, request.topics, records, asked.some((topic) => outcomes.get(topic) === 'denied'));
|
|
2581
|
-
const decidedTopics = asked.filter((topic) => isDecision(outcomes.get(topic)));
|
|
2582
|
-
const observations = scope === 'app'
|
|
2583
|
-
? (states.app === undefined || states.appSource !== 'host' || decidedTopics.length === 0
|
|
2584
|
-
? []
|
|
2585
|
-
: [{ state: states.app, accepted: asked.some((topic) => isAcceptance(outcomes.get(topic))) }])
|
|
2586
|
-
: decidedTopics
|
|
2587
|
-
.filter((topic) => states.sources[topic] === 'host')
|
|
2588
|
-
.map((topic) => ({
|
|
2589
|
-
topic,
|
|
2590
|
-
state: stateIn(states, topic),
|
|
2591
|
-
accepted: isAcceptance(outcomes.get(topic)),
|
|
2592
|
-
}));
|
|
2593
|
-
const pending = permissionRecords(capabilities, observations, result?.evidence, player);
|
|
2594
|
-
if (scope === 'topic') {
|
|
2595
|
-
for (const topic of asked) {
|
|
2596
|
-
// Accepting a requested topic's agreement is the player's request for it.
|
|
2597
|
-
if (isAcceptance(outcomes.get(topic)) && topicOf(topic)?.purpose === 'requested') {
|
|
2598
|
-
pending.push({
|
|
2599
|
-
mode: 'changed',
|
|
2600
|
-
player,
|
|
2601
|
-
record: { kind: 'topic-preference', topic, state: 'granted', source: 'player', at: clock.now() },
|
|
2602
|
-
});
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
await settle(queueRecords(pending));
|
|
2607
|
-
await settle(syncRegistration(capabilities, states.app));
|
|
2608
|
-
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
2609
|
-
return {
|
|
2610
|
-
snapshot: {
|
|
2611
|
-
...(states.app === undefined ? {} : { app: states.app }),
|
|
2612
|
-
topics: states.topics,
|
|
2613
|
-
...(result?.snapshot.requiredAction === undefined
|
|
2614
|
-
? {}
|
|
2615
|
-
: { requiredAction: result.snapshot.requiredAction }),
|
|
2616
|
-
},
|
|
2617
|
-
outcomes: Object.fromEntries(request.topics.map((topic) => [topic, outcomes.get(topic) ?? 'unavailable'])),
|
|
2618
|
-
...(result?.requiredAction === undefined ? {} : { requiredAction: result.requiredAction }),
|
|
2619
|
-
...(result?.evidence === undefined ? {} : { evidence: result.evidence }),
|
|
2620
|
-
};
|
|
2621
|
-
},
|
|
2622
|
-
async revokePermission(input) {
|
|
2623
|
-
assertTopics(input.topics, true);
|
|
2624
|
-
const request = { topics: [...new Set(input.topics)] };
|
|
2625
|
-
const player = sessionPlayer();
|
|
2626
|
-
const capabilities = await capabilitiesOf();
|
|
2627
|
-
const revoke = provider.revokePermission;
|
|
2628
|
-
if (capabilities.permission.prompt !== 'game' || revoke === undefined) {
|
|
2629
|
-
throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
|
|
2630
|
-
message: 'Only a game-rendered consent is withdrawn in the game; others change in OS or host settings.',
|
|
2631
|
-
});
|
|
2632
|
-
}
|
|
2633
|
-
if (capabilities.availability === 'action-required') {
|
|
2634
|
-
// A logged-out player has no consent at the host to withdraw yet.
|
|
2635
|
-
const snapshot = await provider.getPermission({ topics: request.topics });
|
|
2636
|
-
return {
|
|
2637
|
-
snapshot,
|
|
2638
|
-
outcomes: Object.fromEntries(request.topics.map((topic) => [topic, 'action-required'])),
|
|
2639
|
-
requiredAction: snapshot.requiredAction ?? 'login',
|
|
2640
|
-
};
|
|
2641
|
-
}
|
|
2642
|
-
const result = await revoke.call(provider, { topics: request.topics });
|
|
2643
|
-
// Read again: a revocation is a decision, so not opted in reads `denied` from here on.
|
|
2644
|
-
const states = resolveStates(capabilities, await provider.getPermission({ topics: request.topics }), request.topics, undefined, true);
|
|
2645
|
-
if (capabilities.permission.scope === 'app'
|
|
2646
|
-
&& !isDeviceAddressing(capabilities)
|
|
2647
|
-
&& states.app !== undefined
|
|
2648
|
-
&& isRecordableNotificationPermissionState(states.app)) {
|
|
2649
|
-
await settle(queueRecords([{
|
|
2650
|
-
mode: 'changed',
|
|
2651
|
-
player,
|
|
2652
|
-
record: { kind: 'delivery-permission', state: states.app, source: 'player', at: clock.now() },
|
|
2653
|
-
}]));
|
|
2654
|
-
}
|
|
2655
|
-
// A device endpoint stops at the host's answer too.
|
|
2656
|
-
await settle(syncRegistration(capabilities, states.app));
|
|
2657
|
-
await settle(cancelRevokedLocal(capabilities, states, request.topics));
|
|
2658
|
-
return {
|
|
2659
|
-
snapshot: { ...(states.app === undefined ? {} : { app: states.app }), topics: states.topics },
|
|
2660
|
-
outcomes: Object.fromEntries(request.topics.map((topic) => [
|
|
2661
|
-
topic,
|
|
2662
|
-
Object.prototype.hasOwnProperty.call(result.outcomes, topic)
|
|
2663
|
-
? (result.outcomes[topic] ?? 'denied')
|
|
2664
|
-
: 'denied',
|
|
2665
|
-
])),
|
|
2666
|
-
};
|
|
2667
|
-
},
|
|
2668
|
-
async register() {
|
|
2669
|
-
await capabilitiesOf();
|
|
2670
|
-
await registerCurrent(true, true);
|
|
2671
|
-
},
|
|
2672
|
-
async unregister() {
|
|
2673
|
-
// The player signing out, captured before any wait, and the unbind
|
|
2674
|
-
// queued at once, so an authentication that follows is never the one
|
|
2675
|
-
// unbound.
|
|
2676
|
-
const session = backend.session();
|
|
2677
|
-
const unbinding = enqueue('register', async () => {
|
|
2678
|
-
// Persisted: a restart before the next authentication binds nothing.
|
|
2679
|
-
storage.unbound = true;
|
|
2680
|
-
if (session === undefined || !isDeviceAddressing(await provider.getCapabilities())) {
|
|
2681
|
-
return;
|
|
2682
|
-
}
|
|
2683
|
-
await backend.unregisterEndpoint({ provider: provider.provider, player: session.player });
|
|
2684
|
-
if (storage.registration?.session === session.id) {
|
|
2685
|
-
delete storage.registration;
|
|
2686
|
-
}
|
|
2687
|
-
});
|
|
2688
|
-
// The device's pending local reminders belong to the player leaving.
|
|
2689
|
-
// They are cancelled whether or not the unbind reaches the backend (an
|
|
2690
|
-
// offline logout), and either failure is thrown once both have run.
|
|
2691
|
-
const outcomes = await Promise.allSettled([unbinding, cancelLocalWhere(() => true)]);
|
|
2692
|
-
const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
|
|
2693
|
-
if (failures.length > 1) {
|
|
2694
|
-
throw new AggregateError(failures, 'unregister() could neither unbind nor cancel the local reminders.');
|
|
2695
|
-
}
|
|
2696
|
-
if (failures.length === 1) {
|
|
2697
|
-
throw failures[0];
|
|
2698
|
-
}
|
|
2699
|
-
},
|
|
2700
|
-
async setTopicPreference(request) {
|
|
2701
|
-
if (typeof request.topic !== 'string'
|
|
2702
|
-
|| topicOf(request.topic) === undefined
|
|
2703
|
-
|| typeof request.enabled !== 'boolean') {
|
|
2704
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2705
|
-
message: 'setTopicPreference needs a declared topic and a boolean.',
|
|
2706
|
-
});
|
|
2707
|
-
}
|
|
2708
|
-
if (!request.enabled && sessionPlayer() === undefined) {
|
|
2709
|
-
// The device stops showing the topic's reminders even before the
|
|
2710
|
-
// player signs in; the withdrawal itself needs the player.
|
|
2711
|
-
await cancelLocalWhere((entry) => entry.topic === request.topic);
|
|
2712
|
-
}
|
|
2713
|
-
const player = requirePlayer('setTopicPreference');
|
|
2714
|
-
await capabilitiesOf();
|
|
2715
|
-
// The withdrawal is recorded first, so the server stops sending and no
|
|
2716
|
-
// new local reminder passes the gate even if cancelling fails.
|
|
2717
|
-
await queueRecords([{
|
|
2718
|
-
mode: 'changed',
|
|
2719
|
-
player,
|
|
2720
|
-
record: {
|
|
2721
|
-
kind: 'topic-preference',
|
|
2722
|
-
topic: request.topic,
|
|
2723
|
-
state: request.enabled ? 'granted' : 'withdrawn',
|
|
2724
|
-
source: 'player',
|
|
2725
|
-
at: clock.now(),
|
|
2726
|
-
},
|
|
2727
|
-
}]);
|
|
2728
|
-
if (!request.enabled) {
|
|
2729
|
-
await cancelLocalWhere((entry) => entry.topic === request.topic);
|
|
2730
|
-
}
|
|
2731
|
-
},
|
|
2732
|
-
async setAdvertisingConsent(request) {
|
|
2733
|
-
const states = [request.advertising, request.night];
|
|
2734
|
-
if (states.every((state) => state === undefined)
|
|
2735
|
-
|| states.some((state) => state !== undefined && state !== 'granted' && state !== 'withdrawn')
|
|
2736
|
-
|| typeof request.textVersion !== 'string'
|
|
2737
|
-
|| request.textVersion.trim() === '') {
|
|
2738
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2739
|
-
message: 'setAdvertisingConsent needs an advertising or night decision and the consent text version.',
|
|
2740
|
-
});
|
|
2741
|
-
}
|
|
2742
|
-
const player = requirePlayer('setAdvertisingConsent');
|
|
2743
|
-
await capabilitiesOf();
|
|
2744
|
-
// Each call is a consent decision the law asks to notify, so each one is appended.
|
|
2745
|
-
await queueRecords(['advertising', 'night'].flatMap((kind) => {
|
|
2746
|
-
const state = request[kind];
|
|
2747
|
-
return state === undefined
|
|
2748
|
-
? []
|
|
2749
|
-
: [{
|
|
2750
|
-
mode: 'always',
|
|
2751
|
-
player,
|
|
2752
|
-
record: {
|
|
2753
|
-
kind,
|
|
2754
|
-
state,
|
|
2755
|
-
source: 'player',
|
|
2756
|
-
at: clock.now(),
|
|
2757
|
-
textVersion: request.textVersion,
|
|
2758
|
-
},
|
|
2759
|
-
}];
|
|
2760
|
-
}));
|
|
2761
|
-
// Advertising never takes the local route, so there is nothing to cancel.
|
|
2762
|
-
},
|
|
2763
|
-
reminders: {
|
|
2764
|
-
async set(request) {
|
|
2765
|
-
const issue = reminderInputIssue(request);
|
|
2766
|
-
if (issue !== undefined) {
|
|
2767
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2768
|
-
message: `Invalid reminder: ${issue}.`,
|
|
2769
|
-
});
|
|
2770
|
-
}
|
|
2771
|
-
// A server reminder belongs to the player signed in when the call began.
|
|
2772
|
-
const player = sessionPlayer();
|
|
2773
|
-
const capabilities = await capabilitiesOf();
|
|
2774
|
-
const topic = topicOf(request.topic);
|
|
2775
|
-
if (topic === undefined) {
|
|
2776
|
-
return { route: 'none', status: 'unavailable' };
|
|
2777
|
-
}
|
|
2778
|
-
const route = routeNotificationReminder(topic, capabilities);
|
|
2779
|
-
if (route === 'none') {
|
|
2780
|
-
return { route, status: 'unavailable' };
|
|
2781
|
-
}
|
|
2782
|
-
if (route === 'remote' && capabilities.remote.content === 'host-template' && request.templateData !== undefined) {
|
|
2783
|
-
// Template context stays on the server, as in v1.
|
|
2784
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2785
|
-
message: 'A host-template provider takes no templateData in a reminder.',
|
|
2786
|
-
});
|
|
2787
|
-
}
|
|
2788
|
-
if (capabilities.availability === 'action-required') {
|
|
2789
|
-
return { route, status: 'action-required' };
|
|
2790
|
-
}
|
|
2791
|
-
if (route === 'local') {
|
|
2792
|
-
// In the local queue, so a withdrawal or revocation sweep that starts
|
|
2793
|
-
// while this reads consent runs after the schedule and cancels it.
|
|
2794
|
-
return enqueue('local', () => scheduleLocalReminder(capabilities, topic, request, player));
|
|
2795
|
-
}
|
|
2796
|
-
// A server reminder needs the signed-in player, checked before anything changes.
|
|
2797
|
-
requireSignedIn('reminders.set', player);
|
|
2798
|
-
// One key across both routes: the local reminder with the key goes first.
|
|
2799
|
-
if (capabilities.local.supported) {
|
|
2800
|
-
await enqueue('local', () => removeLocalKey(request.key));
|
|
2801
|
-
}
|
|
2802
|
-
assertSamePlayer('reminders.set', player);
|
|
2803
|
-
return {
|
|
2804
|
-
route,
|
|
2805
|
-
status: await backend.setReminder({
|
|
2806
|
-
key: request.key,
|
|
2807
|
-
topic: topic.id,
|
|
2808
|
-
fireAt: normalizeNotificationTimestamp(request.fireAt),
|
|
2809
|
-
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
2810
|
-
...(request.link === undefined ? {} : { link: request.link }),
|
|
2811
|
-
}),
|
|
2812
|
-
};
|
|
2813
|
-
},
|
|
2814
|
-
async cancel(request) {
|
|
2815
|
-
const issue = notificationKeyIssue(request.key);
|
|
2816
|
-
if (issue !== undefined) {
|
|
2817
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2818
|
-
message: `Invalid reminder key: ${issue}.`,
|
|
2819
|
-
});
|
|
2820
|
-
}
|
|
2821
|
-
const player = sessionPlayer();
|
|
2822
|
-
const capabilities = await capabilitiesOf();
|
|
2823
|
-
// One key across both routes: each route that has the key loses it.
|
|
2824
|
-
const local = capabilities.local.supported
|
|
2825
|
-
&& (await enqueue('local', () => removeLocalKey(request.key)));
|
|
2826
|
-
let remote = false;
|
|
2827
|
-
if (capabilities.remote.supported) {
|
|
2828
|
-
if (capabilities.availability === 'action-required') {
|
|
2829
|
-
return { route: 'remote', status: 'action-required' };
|
|
2830
|
-
}
|
|
2831
|
-
requireSignedIn('reminders.cancel', player);
|
|
2832
|
-
remote = await backend.cancelReminder({ key: request.key });
|
|
2833
|
-
}
|
|
2834
|
-
// Both routes have the key only after a local reminder was set while
|
|
2835
|
-
// signed out; the local one is the latest set, so it answers.
|
|
2836
|
-
if (local) {
|
|
2837
|
-
return { route: 'local', status: 'cancelled' };
|
|
2838
|
-
}
|
|
2839
|
-
// Nothing is pending under the key any more.
|
|
2840
|
-
return { route: remote ? 'remote' : 'none', status: 'cancelled' };
|
|
2841
|
-
},
|
|
2842
|
-
},
|
|
2843
|
-
notices: {
|
|
2844
|
-
async list() {
|
|
2845
|
-
// The notices of the player signed in when the call began.
|
|
2846
|
-
const player = sessionPlayer();
|
|
2847
|
-
await capabilitiesOf();
|
|
2848
|
-
requireSignedIn('notices.list', player);
|
|
2849
|
-
const notices = await backend.listNotices();
|
|
2850
|
-
assertSamePlayer('notices.list', player);
|
|
2851
|
-
return notices;
|
|
2852
|
-
},
|
|
2853
|
-
async recordShown(request) {
|
|
2854
|
-
const player = sessionPlayer();
|
|
2855
|
-
// The backend gets only the name, with the record time in the canonical
|
|
2856
|
-
// form; a name that matches no listed notice records nothing.
|
|
2857
|
-
const notice = noticeIdentityOf(recordNoticeInputOf(request));
|
|
2858
|
-
await capabilitiesOf();
|
|
2859
|
-
requireSignedIn('notices.recordShown', player);
|
|
2860
|
-
await backend.recordNotice({ ...notice, channel: 'in-game' });
|
|
1676
|
+
const kept = input.storage ?? {};
|
|
1677
|
+
return createNotificationRuntime({
|
|
1678
|
+
provider: input.provider,
|
|
1679
|
+
backend: input.backend,
|
|
1680
|
+
catalog: input.catalog,
|
|
1681
|
+
clock: input.clock,
|
|
1682
|
+
timeZone: input.timeZone,
|
|
1683
|
+
defaultLocale: input.defaultLocale,
|
|
1684
|
+
...(input.locale === undefined ? {} : { locale: input.locale }),
|
|
1685
|
+
...(input.registryEntry === undefined ? {} : { registryEntry: input.registryEntry }),
|
|
1686
|
+
storage: {
|
|
1687
|
+
load: () => ({ ...kept }),
|
|
1688
|
+
save(state) {
|
|
1689
|
+
delete kept.registration;
|
|
1690
|
+
delete kept.unbound;
|
|
1691
|
+
delete kept.unboundSession;
|
|
1692
|
+
Object.assign(kept, state);
|
|
2861
1693
|
},
|
|
2862
1694
|
},
|
|
2863
|
-
async openSettings() {
|
|
2864
|
-
const capabilities = await capabilitiesOf();
|
|
2865
|
-
const openSettings = provider.openSettings;
|
|
2866
|
-
return capabilities.permission.settings && openSettings !== undefined
|
|
2867
|
-
? openSettings.call(provider)
|
|
2868
|
-
: 'unavailable';
|
|
2869
|
-
},
|
|
2870
|
-
subscribe(listener) {
|
|
2871
|
-
listeners.add(listener);
|
|
2872
|
-
if (!gameSubscribed) {
|
|
2873
|
-
gameSubscribed = true;
|
|
2874
|
-
const drained = buffer;
|
|
2875
|
-
buffer = [];
|
|
2876
|
-
// The first subscriber receives every buffered event once, even if it throws on one.
|
|
2877
|
-
for (const error of deliverEach([listener], drained)) {
|
|
2878
|
-
fail(error);
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
return () => {
|
|
2882
|
-
listeners.delete(listener);
|
|
2883
|
-
};
|
|
2884
|
-
},
|
|
2885
|
-
};
|
|
2886
|
-
}
|
|
2887
|
-
function assertTopics(topics, nonEmpty) {
|
|
2888
|
-
if (!Array.isArray(topics)
|
|
2889
|
-
|| (nonEmpty && topics.length === 0)
|
|
2890
|
-
|| topics.some((topic) => typeof topic !== 'string' || topic.length === 0)) {
|
|
2891
|
-
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2892
|
-
message: nonEmpty ? 'Expected at least one topic id.' : 'Expected a list of topic ids.',
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
|
-
}
|
|
2896
|
-
/**
|
|
2897
|
-
* The snapshot after a request: the host's answer for the asked topics over
|
|
2898
|
-
* the read before it, and the answer a prompt just gave for a topic the host
|
|
2899
|
-
* cannot read back.
|
|
2900
|
-
*/
|
|
2901
|
-
function mergeSnapshots(before, after, asked, outcomes, scope) {
|
|
2902
|
-
const topics = new Map(Object.entries(before.topics));
|
|
2903
|
-
for (const [topic, state] of Object.entries(after?.topics ?? {})) {
|
|
2904
|
-
topics.set(topic, state);
|
|
2905
|
-
}
|
|
2906
|
-
let app = after?.app ?? before.app;
|
|
2907
|
-
for (const topic of asked) {
|
|
2908
|
-
const decided = outcomeState(outcomes.get(topic));
|
|
2909
|
-
if (decided === undefined) {
|
|
2910
|
-
continue;
|
|
2911
|
-
}
|
|
2912
|
-
if (scope === 'app') {
|
|
2913
|
-
app = app === undefined || app === 'unknown' ? decided : app;
|
|
2914
|
-
}
|
|
2915
|
-
else if ((topics.get(topic) ?? 'unknown') === 'unknown') {
|
|
2916
|
-
topics.set(topic, decided);
|
|
2917
|
-
}
|
|
2918
|
-
}
|
|
2919
|
-
if (scope === 'app' && app !== undefined) {
|
|
2920
|
-
// An app-scope topic reads the app state; an `unknown` entry for it does not outlive the answer.
|
|
2921
|
-
for (const topic of asked) {
|
|
2922
|
-
if (topics.get(topic) === 'unknown') {
|
|
2923
|
-
topics.set(topic, app);
|
|
2924
|
-
}
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
return { ...(app === undefined ? {} : { app }), topics: Object.fromEntries(topics) };
|
|
2928
|
-
}
|
|
2929
|
-
/** The state a decided outcome leaves when the host cannot be read. */
|
|
2930
|
-
function outcomeState(outcome) {
|
|
2931
|
-
switch (outcome) {
|
|
2932
|
-
case 'granted':
|
|
2933
|
-
case 'already-granted':
|
|
2934
|
-
return 'granted';
|
|
2935
|
-
case 'denied':
|
|
2936
|
-
return 'denied';
|
|
2937
|
-
case 'blocked':
|
|
2938
|
-
return 'blocked';
|
|
2939
|
-
default:
|
|
2940
|
-
return undefined;
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
function stateIn(states, topic) {
|
|
2944
|
-
return Object.prototype.hasOwnProperty.call(states.topics, topic)
|
|
2945
|
-
? (states.topics[topic] ?? 'unknown')
|
|
2946
|
-
: (states.app ?? 'unknown');
|
|
2947
|
-
}
|
|
2948
|
-
function isDeviceAddressing(capabilities) {
|
|
2949
|
-
return capabilities.remote.supported && capabilities.remote.addressing === 'device';
|
|
2950
|
-
}
|
|
2951
|
-
/** A known permission other than `granted`: the device no longer shows local reminders. */
|
|
2952
|
-
function revokesLocalReminders(state) {
|
|
2953
|
-
return state === 'prompt'
|
|
2954
|
-
|| state === 'prompt-with-rationale'
|
|
2955
|
-
|| state === 'denied'
|
|
2956
|
-
|| state === 'blocked';
|
|
2957
|
-
}
|
|
2958
|
-
function isAcceptance(outcome) {
|
|
2959
|
-
return outcome === 'granted' || outcome === 'already-granted';
|
|
2960
|
-
}
|
|
2961
|
-
/** An answer the player gave: never `dismissed`, `action-required`, or `unavailable`. */
|
|
2962
|
-
function isDecision(outcome) {
|
|
2963
|
-
return outcomeState(outcome) !== undefined;
|
|
2964
|
-
}
|
|
2965
|
-
/** The latest permission state recorded for a kind and scope; `withdrawn` is not a permission state. */
|
|
2966
|
-
function latestRecordState(records, kind, topic) {
|
|
2967
|
-
const state = latestNotificationConsent(records, kind, topic)?.state;
|
|
2968
|
-
return state === undefined || state === 'withdrawn' ? undefined : state;
|
|
2969
|
-
}
|
|
2970
|
-
/**
|
|
2971
|
-
* A pending reminder's `fireAt` as the provider listed it, which the daily cap
|
|
2972
|
-
* reads: one that does not parse strictly is the provider breaking the
|
|
2973
|
-
* contract, not the game's input.
|
|
2974
|
-
*/
|
|
2975
|
-
function providerFireAt(fireAt) {
|
|
2976
|
-
if (!isNotificationTimestamp(fireAt)) {
|
|
2977
|
-
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
2978
|
-
message: `The provider listed a local reminder whose fireAt is not RFC 3339 with a date that exists: ${JSON.stringify(fireAt)}.`,
|
|
2979
|
-
});
|
|
2980
|
-
}
|
|
2981
|
-
return fireAt;
|
|
2982
|
-
}
|
|
2983
|
-
/** Whether the topic preference alone refuses a reminder of `topic` now. */
|
|
2984
|
-
function preferenceRefuses(topic, records, at) {
|
|
2985
|
-
const decision = evaluateLocalNotificationReminder({
|
|
2986
|
-
topic,
|
|
2987
|
-
permission: 'granted',
|
|
2988
|
-
consent: records,
|
|
2989
|
-
fireAt: at,
|
|
2990
|
-
timeZone: 'UTC',
|
|
2991
1695
|
});
|
|
2992
|
-
return decision.status === 'request-missing' || decision.status === 'preference-off';
|
|
2993
|
-
}
|
|
2994
|
-
function hasLocalCopy(topic, locale, defaultLocale) {
|
|
2995
|
-
return (typeof topic.mapping?.['title'] === 'string' && typeof topic.mapping['body'] === 'string')
|
|
2996
|
-
|| findNotificationCopy(topic, locale, defaultLocale) !== undefined;
|
|
2997
|
-
}
|
|
2998
|
-
function sameJson(left, right) {
|
|
2999
|
-
return JSON.stringify(left) === JSON.stringify(right);
|
|
3000
1696
|
}
|
|
3001
1697
|
export function createNotificationConformanceHarness(context) {
|
|
3002
1698
|
const host = createNotificationConformanceHost({
|
|
@@ -3537,6 +2233,3 @@ function matchPartial(expected, actual, path, failures) {
|
|
|
3537
2233
|
failures.push({ path, expected, actual });
|
|
3538
2234
|
}
|
|
3539
2235
|
}
|
|
3540
|
-
function describeError(error) {
|
|
3541
|
-
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
3542
|
-
}
|