pulse-updates 1.3.8 → 1.3.10
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/README.md +5 -1
- package/android/src/main/java/app/pulse/updates/PulseController.kt +29 -0
- package/ios/PulseUpdates/PulseController.swift +10 -0
- package/lib/commonjs/links.js +146 -21
- package/lib/commonjs/links.js.map +1 -1
- package/lib/module/links.js +145 -21
- package/lib/module/links.js.map +1 -1
- package/lib/typescript/links.d.ts +25 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/package.json +4 -2
- package/scripts/publish.mjs +227 -16
- package/src/links.ts +196 -21
package/src/links.ts
CHANGED
|
@@ -122,6 +122,14 @@ export interface AnonymousFirstOpenContext {
|
|
|
122
122
|
/** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
|
|
123
123
|
timezoneOffsetMinutes?: number;
|
|
124
124
|
isEmulator?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The address the person is signed in with, when the host app has one. An install that arrives
|
|
127
|
+
* with an account does not need to be guessed at: the resolver binds it to the message sent to
|
|
128
|
+
* that address and skips probabilistic matching entirely. Omitted for signed-out first opens,
|
|
129
|
+
* and never read from the clipboard or any other ambient source — the app passes what it
|
|
130
|
+
* already knows, or nothing.
|
|
131
|
+
*/
|
|
132
|
+
accountEmail?: string;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
export type AnonymousFirstOpenResult =
|
|
@@ -261,6 +269,17 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
|
|
|
261
269
|
randomUUID?: () => string;
|
|
262
270
|
/** Phase-one public campaign exposures are 128-bit hex; account tokens use the wider grammar. */
|
|
263
271
|
isPublicToken?: (token: string) => boolean;
|
|
272
|
+
/**
|
|
273
|
+
* Whether a first open may name the signed-in account.
|
|
274
|
+
*
|
|
275
|
+
* Off by default, and deliberately a second switch rather than "the host passed one, so send
|
|
276
|
+
* it": an address is the only piece of the first-open request that identifies a person, and an
|
|
277
|
+
* integrator adopting Pulse must choose to send it rather than discover later that reading their
|
|
278
|
+
* own auth state started forwarding addresses. With it on, an install that arrives signed in is
|
|
279
|
+
* bound to the message sent to that address instead of being inferred from device shape and
|
|
280
|
+
* timing — the only deterministic signal left where no store referrer survives.
|
|
281
|
+
*/
|
|
282
|
+
sendAccountEmailOnFirstOpen?: boolean;
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
export interface DeferredLinkClient {
|
|
@@ -574,6 +593,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
574
593
|
let accountRetryAt = 0;
|
|
575
594
|
let accountAttempts = 0;
|
|
576
595
|
let deterministicEpoch = 0;
|
|
596
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
597
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
598
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
599
|
+
// state write, retry and outcome from the old instance.
|
|
600
|
+
let lifecycleGeneration = 0;
|
|
577
601
|
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
578
602
|
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
579
603
|
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
@@ -796,6 +820,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
796
820
|
status: DeferredLinkStatus,
|
|
797
821
|
outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
|
|
798
822
|
): void => {
|
|
823
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
824
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
825
|
+
const completionIsCurrent = (): boolean => !disposed
|
|
826
|
+
&& lifecycleGeneration === completionLifecycleGeneration
|
|
827
|
+
&& deterministicEpoch === completionDeterministicEpoch;
|
|
799
828
|
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
800
829
|
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
801
830
|
try {
|
|
@@ -804,6 +833,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
804
833
|
shouldQueue = false;
|
|
805
834
|
reportError(error);
|
|
806
835
|
}
|
|
836
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
837
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
838
|
+
if (!completionIsCurrent()) return;
|
|
807
839
|
}
|
|
808
840
|
if (!shouldQueue) {
|
|
809
841
|
state = {
|
|
@@ -852,21 +884,36 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
852
884
|
void flushTerminalDelivery();
|
|
853
885
|
};
|
|
854
886
|
|
|
855
|
-
const blockFirstOpenForDeterministic = ():
|
|
887
|
+
const blockFirstOpenForDeterministic = (): boolean => {
|
|
888
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
856
889
|
deterministicEpoch += 1;
|
|
857
|
-
|
|
890
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
891
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
858
892
|
state = {
|
|
859
893
|
...state,
|
|
860
894
|
firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
|
|
861
895
|
};
|
|
862
896
|
persistAndNotify();
|
|
897
|
+
return !disposed
|
|
898
|
+
&& lifecycleGeneration === blockingLifecycleGeneration
|
|
899
|
+
&& deterministicEpoch === blockingDeterministicEpoch;
|
|
863
900
|
};
|
|
864
901
|
|
|
865
|
-
const
|
|
866
|
-
|
|
902
|
+
const readAccountReadiness = (): { ready: boolean; current: boolean } => {
|
|
903
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
904
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
905
|
+
let ready = false;
|
|
906
|
+
try { ready = options.isAccountReady?.() ?? false; } catch (error) {
|
|
867
907
|
reportError(error);
|
|
868
|
-
return false;
|
|
869
908
|
}
|
|
909
|
+
return {
|
|
910
|
+
ready,
|
|
911
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
912
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
913
|
+
current: !disposed
|
|
914
|
+
&& lifecycleGeneration === readinessLifecycleGeneration
|
|
915
|
+
&& deterministicEpoch === readinessDeterministicEpoch,
|
|
916
|
+
};
|
|
870
917
|
};
|
|
871
918
|
|
|
872
919
|
const schedulePendingRetry = (): void => {
|
|
@@ -1101,7 +1148,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1101
1148
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
1102
1149
|
return false;
|
|
1103
1150
|
}
|
|
1104
|
-
|
|
1151
|
+
const accountReadiness = readAccountReadiness();
|
|
1152
|
+
if (!accountReadiness.current) return false;
|
|
1153
|
+
if (!accountReadiness.ready) {
|
|
1105
1154
|
patchState({ status: 'waiting_for_account' });
|
|
1106
1155
|
return false;
|
|
1107
1156
|
}
|
|
@@ -1122,6 +1171,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1122
1171
|
let applicationFailed = false;
|
|
1123
1172
|
let applicationError: unknown;
|
|
1124
1173
|
const applicationEpoch = deterministicEpoch;
|
|
1174
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
1175
|
+
const lifecycleIsCurrent = (): boolean => !disposed
|
|
1176
|
+
&& lifecycleGeneration === applicationLifecycleGeneration;
|
|
1125
1177
|
const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
|
|
1126
1178
|
&& state.pending !== null
|
|
1127
1179
|
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
@@ -1129,7 +1181,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1129
1181
|
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
1130
1182
|
routingApplicationInFlight = true;
|
|
1131
1183
|
emitOutcome(link, 'app_open_confirmed');
|
|
1184
|
+
if (!lifecycleIsCurrent()) {
|
|
1185
|
+
routingApplicationInFlight = false;
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1132
1188
|
emitOutcome(link, 'deferred_link_resolved');
|
|
1189
|
+
if (!lifecycleIsCurrent()) {
|
|
1190
|
+
routingApplicationInFlight = false;
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1133
1193
|
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
1134
1194
|
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
1135
1195
|
if (hasNewerPendingIntent()) {
|
|
@@ -1153,10 +1213,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1153
1213
|
routingApplicationInFlight = false;
|
|
1154
1214
|
}
|
|
1155
1215
|
|
|
1216
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
1217
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
1218
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
1219
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1220
|
+
|
|
1156
1221
|
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
1157
1222
|
if (applicationFailed) {
|
|
1158
1223
|
reportError(applicationError);
|
|
1159
|
-
if (
|
|
1224
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1225
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
1226
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
1227
|
+
// observed before diagnostics ran.
|
|
1228
|
+
if (hasNewerPendingIntent()) {
|
|
1160
1229
|
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
1161
1230
|
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
1162
1231
|
void process();
|
|
@@ -1200,11 +1269,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1200
1269
|
appliedIds,
|
|
1201
1270
|
};
|
|
1202
1271
|
persistAndNotify();
|
|
1272
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1203
1273
|
if (link.matchBasis === 'account_bound') {
|
|
1204
1274
|
accountAttempts = 0;
|
|
1205
1275
|
accountRetryAt = 0;
|
|
1206
1276
|
}
|
|
1207
1277
|
emitOutcome(link, 'action_applied');
|
|
1278
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1208
1279
|
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
1209
1280
|
void process();
|
|
1210
1281
|
}
|
|
@@ -1258,9 +1329,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1258
1329
|
return;
|
|
1259
1330
|
}
|
|
1260
1331
|
const token = pending.token;
|
|
1332
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
1261
1333
|
patchState({ status: 'resolving' });
|
|
1334
|
+
if (disposed
|
|
1335
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1336
|
+
|| state.pending?.token !== token) return;
|
|
1262
1337
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
1263
|
-
if (disposed
|
|
1338
|
+
if (disposed
|
|
1339
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1340
|
+
|| state.pending?.token !== token) return;
|
|
1264
1341
|
if (publicResult.kind === 'resolved') {
|
|
1265
1342
|
await applyResolved(publicResult.link, token);
|
|
1266
1343
|
return;
|
|
@@ -1270,7 +1347,16 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1270
1347
|
return;
|
|
1271
1348
|
}
|
|
1272
1349
|
|
|
1273
|
-
if (!options.accountBridge
|
|
1350
|
+
if (!options.accountBridge) {
|
|
1351
|
+
patchState({ status: 'waiting_for_account' });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
const accountReadiness = readAccountReadiness();
|
|
1355
|
+
if (!accountReadiness.current
|
|
1356
|
+
|| disposed
|
|
1357
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1358
|
+
|| state.pending?.token !== token) return;
|
|
1359
|
+
if (!accountReadiness.ready) {
|
|
1274
1360
|
patchState({ status: 'waiting_for_account' });
|
|
1275
1361
|
return;
|
|
1276
1362
|
}
|
|
@@ -1280,7 +1366,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1280
1366
|
requestTimeoutMs,
|
|
1281
1367
|
'account claim',
|
|
1282
1368
|
);
|
|
1283
|
-
if (disposed
|
|
1369
|
+
if (disposed
|
|
1370
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1371
|
+
|| state.pending?.token !== token) return;
|
|
1284
1372
|
const normalizedClaim = normalizeResolved<Action>(
|
|
1285
1373
|
raw,
|
|
1286
1374
|
token,
|
|
@@ -1295,19 +1383,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1295
1383
|
confidence: 1,
|
|
1296
1384
|
} : null;
|
|
1297
1385
|
if (claimed) {
|
|
1298
|
-
blockFirstOpenForDeterministic();
|
|
1386
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1299
1387
|
await applyResolved(claimed, token);
|
|
1300
1388
|
}
|
|
1301
1389
|
else clearPendingAsTerminal();
|
|
1302
1390
|
} catch (error) {
|
|
1391
|
+
if (disposed
|
|
1392
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1393
|
+
|| state.pending?.token !== token) return;
|
|
1303
1394
|
reportError(error);
|
|
1304
|
-
if (
|
|
1395
|
+
if (!disposed
|
|
1396
|
+
&& lifecycleGeneration === pendingLifecycleGeneration
|
|
1397
|
+
&& state.pending?.token === token) schedulePendingRetry();
|
|
1305
1398
|
}
|
|
1306
1399
|
return;
|
|
1307
1400
|
}
|
|
1308
1401
|
|
|
1309
|
-
if (!options.accountBridge
|
|
1402
|
+
if (!options.accountBridge) return;
|
|
1403
|
+
const accountReadiness = readAccountReadiness();
|
|
1404
|
+
if (!accountReadiness.current || state.pending) return;
|
|
1405
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
1310
1406
|
const accountPendingEpoch = deterministicEpoch;
|
|
1407
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
1311
1408
|
try {
|
|
1312
1409
|
const raw = await withPromiseTimeout(
|
|
1313
1410
|
() => options.accountBridge!.pending(),
|
|
@@ -1317,7 +1414,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1317
1414
|
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
1318
1415
|
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
1319
1416
|
// captured token; otherwise both deterministic destinations could be applied.
|
|
1320
|
-
if (disposed
|
|
1417
|
+
if (disposed
|
|
1418
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1419
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1420
|
+
|| state.pending) return;
|
|
1321
1421
|
if (!raw) {
|
|
1322
1422
|
accountAttempts = 0;
|
|
1323
1423
|
accountRetryAt = 0;
|
|
@@ -1346,7 +1446,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1346
1446
|
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
1347
1447
|
// callback. This also covers an already-applied account item restored from older SDK
|
|
1348
1448
|
// state, which must still prevent a second probabilistic destination.
|
|
1349
|
-
blockFirstOpenForDeterministic();
|
|
1449
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1350
1450
|
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
1351
1451
|
await applyResolved(pendingLink, null);
|
|
1352
1452
|
} else if (state.status !== 'applied') {
|
|
@@ -1354,8 +1454,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1354
1454
|
}
|
|
1355
1455
|
}
|
|
1356
1456
|
} catch (error) {
|
|
1357
|
-
if (disposed
|
|
1457
|
+
if (disposed
|
|
1458
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1459
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1460
|
+
|| state.pending) return;
|
|
1358
1461
|
reportError(error);
|
|
1462
|
+
if (disposed
|
|
1463
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1464
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1465
|
+
|| state.pending) return;
|
|
1359
1466
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
1360
1467
|
scheduleAccountRetry();
|
|
1361
1468
|
}
|
|
@@ -1451,7 +1558,14 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1451
1558
|
lastFirstOpenContext = context;
|
|
1452
1559
|
if (state.pending) return 'deterministic_pending';
|
|
1453
1560
|
if (state.firstOpen.completed) return 'already_completed';
|
|
1454
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1561
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1562
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1563
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1564
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1565
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1566
|
+
scheduleWake();
|
|
1567
|
+
return 'backoff';
|
|
1568
|
+
}
|
|
1455
1569
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1456
1570
|
|
|
1457
1571
|
// The client starts account recovery at construction. Join that deterministic rail before
|
|
@@ -1460,15 +1574,22 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1460
1574
|
await process();
|
|
1461
1575
|
if (disposed) return 'ineligible';
|
|
1462
1576
|
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1463
|
-
if (options.accountBridge
|
|
1464
|
-
|
|
1465
|
-
|
|
1577
|
+
if (options.accountBridge) {
|
|
1578
|
+
const accountReadiness = readAccountReadiness();
|
|
1579
|
+
if (!accountReadiness.current) {
|
|
1580
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1581
|
+
}
|
|
1582
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1583
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1584
|
+
return 'retry_scheduled';
|
|
1585
|
+
}
|
|
1466
1586
|
}
|
|
1467
1587
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1468
1588
|
|
|
1469
1589
|
const installAttemptId = state.firstOpen.installAttemptId
|
|
1470
1590
|
?? makeInstallAttemptId(options.randomUUID);
|
|
1471
1591
|
const startingDeterministicEpoch = deterministicEpoch;
|
|
1592
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
1472
1593
|
state = {
|
|
1473
1594
|
...state,
|
|
1474
1595
|
status: 'resolving',
|
|
@@ -1478,8 +1599,37 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1478
1599
|
attemptedAt: now(),
|
|
1479
1600
|
},
|
|
1480
1601
|
};
|
|
1481
|
-
persistAndNotify();
|
|
1602
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1603
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1604
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1605
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1606
|
+
if (disposed) return 'ineligible';
|
|
1607
|
+
if (lifecycleGeneration !== startingLifecycleGeneration
|
|
1608
|
+
|| deterministicEpoch !== startingDeterministicEpoch
|
|
1609
|
+
|| state.pending
|
|
1610
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1611
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1612
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1613
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1614
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1615
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1616
|
+
// continue normally.
|
|
1617
|
+
state = {
|
|
1618
|
+
...state,
|
|
1619
|
+
status: 'retryable_error',
|
|
1620
|
+
firstOpen: {
|
|
1621
|
+
...state.firstOpen,
|
|
1622
|
+
nextRetryAt: now() + retryBaseMs,
|
|
1623
|
+
},
|
|
1624
|
+
};
|
|
1625
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1626
|
+
notifyState();
|
|
1627
|
+
return 'retry_scheduled';
|
|
1628
|
+
}
|
|
1482
1629
|
|
|
1630
|
+
const accountEmail = options.sendAccountEmailOnFirstOpen === true
|
|
1631
|
+
? normalizeAccountEmail(context.accountEmail)
|
|
1632
|
+
: null;
|
|
1483
1633
|
const body = {
|
|
1484
1634
|
appBundleId: context.appBundleId.trim(),
|
|
1485
1635
|
platform: 'ios' as const,
|
|
@@ -1487,6 +1637,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1487
1637
|
firstOpen: true,
|
|
1488
1638
|
installAttemptId,
|
|
1489
1639
|
...normalizeAnonymousFirstOpenSignals(context),
|
|
1640
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1641
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1642
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1643
|
+
...(accountEmail ? { accountEmail } : {}),
|
|
1490
1644
|
};
|
|
1491
1645
|
try {
|
|
1492
1646
|
const response = await withTimeout(
|
|
@@ -1637,6 +1791,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1637
1791
|
const captureAndroidInstallReferrer = async (
|
|
1638
1792
|
bridge: AndroidInstallReferrerBridge,
|
|
1639
1793
|
): Promise<AndroidInstallReferrerResult> => {
|
|
1794
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1795
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
1640
1796
|
let raw: unknown;
|
|
1641
1797
|
try {
|
|
1642
1798
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -1645,6 +1801,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1645
1801
|
return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
|
|
1646
1802
|
}
|
|
1647
1803
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1804
|
+
if (disposed
|
|
1805
|
+
|| lifecycleGeneration !== referrerLifecycleGeneration
|
|
1806
|
+
|| deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
1648
1807
|
if (result.status === 'OK' && result.token) {
|
|
1649
1808
|
capture(result.token, 'android_install_referrer');
|
|
1650
1809
|
}
|
|
@@ -1708,6 +1867,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1708
1867
|
dispose: () => {
|
|
1709
1868
|
if (disposed) return;
|
|
1710
1869
|
disposed = true;
|
|
1870
|
+
lifecycleGeneration += 1;
|
|
1711
1871
|
deterministicEpoch += 1;
|
|
1712
1872
|
terminalDeliveryGeneration += 1;
|
|
1713
1873
|
if (retryTimer) clearTimeout(retryTimer);
|
|
@@ -1719,6 +1879,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1719
1879
|
reset: () => {
|
|
1720
1880
|
accountRetryAt = 0;
|
|
1721
1881
|
accountAttempts = 0;
|
|
1882
|
+
lifecycleGeneration += 1;
|
|
1722
1883
|
deterministicEpoch += 1;
|
|
1723
1884
|
terminalDeliveryGeneration += 1;
|
|
1724
1885
|
terminalDeliverySending = null;
|
|
@@ -1967,6 +2128,20 @@ function resolverOutcomeRetryDelay(
|
|
|
1967
2128
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1968
2129
|
}
|
|
1969
2130
|
|
|
2131
|
+
/**
|
|
2132
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
2133
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
2134
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
2135
|
+
*/
|
|
2136
|
+
export function normalizeAccountEmail(raw: string | undefined | null): string | null {
|
|
2137
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
2138
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
2139
|
+
const at = value.indexOf('@');
|
|
2140
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
2141
|
+
if (/\s/.test(value)) return null;
|
|
2142
|
+
return value;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1970
2145
|
async function withTimeout(
|
|
1971
2146
|
fetcher: typeof fetch,
|
|
1972
2147
|
input: string,
|