pulse-updates 1.3.0 → 1.3.2
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/ios/PulseAttribution/PulseAttribution.mm +45 -0
- package/lib/commonjs/links.js +219 -40
- package/lib/commonjs/links.js.map +1 -1
- package/lib/module/links.js +219 -40
- package/lib/module/links.js.map +1 -1
- package/lib/typescript/links.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/links.ts +288 -42
- package/ios/PulseAttribution/PulseAttribution.m +0 -29
package/src/links.ts
CHANGED
|
@@ -190,6 +190,7 @@ export interface DeferredLinkClient {
|
|
|
190
190
|
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
191
191
|
const PUBLIC_EXPOSURE = /^[a-f0-9]{32}$/i;
|
|
192
192
|
const INSTALL_ATTEMPT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
193
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
193
194
|
const APP_IDENTITY = /^[A-Za-z0-9.-]+$/;
|
|
194
195
|
const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
195
196
|
const DEFAULT_PREFIXES = ['encore_handoff:', 'pulse_handoff:'] as const;
|
|
@@ -244,7 +245,22 @@ const DEFAULT_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
|
244
245
|
const DEFAULT_RECENT_INSTALL_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
245
246
|
const MAX_APPLIED_IDS = 32;
|
|
246
247
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
247
|
-
const
|
|
248
|
+
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
249
|
+
const MAX_PERSISTED_BYTES = 131_072;
|
|
250
|
+
|
|
251
|
+
interface QueuedResolverOutcome {
|
|
252
|
+
/** Non-reversible local transition key; never sent over the network. */
|
|
253
|
+
transitionKey: string;
|
|
254
|
+
/** Kept only in app storage and the Encore request path. Never added to analytics/body. */
|
|
255
|
+
token: string;
|
|
256
|
+
name: DeferredLinkOutcomeName;
|
|
257
|
+
eventId: string;
|
|
258
|
+
occurredAt: string;
|
|
259
|
+
matchBasis: DeferredLinkMatchBasis;
|
|
260
|
+
confidence: number;
|
|
261
|
+
attempts: number;
|
|
262
|
+
nextRetryAt: number;
|
|
263
|
+
}
|
|
248
264
|
|
|
249
265
|
interface PersistedDeferredLinkState {
|
|
250
266
|
version: 1;
|
|
@@ -254,6 +270,8 @@ interface PersistedDeferredLinkState {
|
|
|
254
270
|
appliedIds: string[];
|
|
255
271
|
/** Internal transition-once ledger of non-reversible bounded hashes. */
|
|
256
272
|
notifiedOutcomes: string[];
|
|
273
|
+
/** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
|
|
274
|
+
outcomeQueue: QueuedResolverOutcome[];
|
|
257
275
|
firstOpen: DeferredLinkFirstOpenState;
|
|
258
276
|
}
|
|
259
277
|
|
|
@@ -269,6 +287,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
|
|
|
269
287
|
lastAppliedId: null,
|
|
270
288
|
appliedIds: [],
|
|
271
289
|
notifiedOutcomes: [],
|
|
290
|
+
outcomeQueue: [],
|
|
272
291
|
firstOpen: {
|
|
273
292
|
installAttemptId: null,
|
|
274
293
|
completed: false,
|
|
@@ -395,6 +414,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
395
414
|
let processing: Promise<void> | null = null;
|
|
396
415
|
let processRequested = false;
|
|
397
416
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
417
|
+
let outcomeSending: Promise<number> | null = null;
|
|
398
418
|
let accountRetryAt = 0;
|
|
399
419
|
let accountAttempts = 0;
|
|
400
420
|
let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
|
|
@@ -475,44 +495,164 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
475
495
|
patchState({ pending: null, status: 'terminal_error' });
|
|
476
496
|
};
|
|
477
497
|
|
|
498
|
+
const persistOutcomeState = (): void => {
|
|
499
|
+
writeState(options.storage, storageKey, state);
|
|
500
|
+
scheduleWake();
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const flushResolverOutcomes = async (): Promise<number> => {
|
|
504
|
+
if (disposed) return 0;
|
|
505
|
+
if (outcomeSending) return outcomeSending;
|
|
506
|
+
|
|
507
|
+
const run = async (): Promise<number> => {
|
|
508
|
+
let delivered = 0;
|
|
509
|
+
while (!disposed) {
|
|
510
|
+
const queued = state.outcomeQueue[0];
|
|
511
|
+
if (!queued || queued.nextRetryAt > now()) break;
|
|
512
|
+
|
|
513
|
+
let response: Response;
|
|
514
|
+
try {
|
|
515
|
+
response = await postResolverOutcome(
|
|
516
|
+
fetcher,
|
|
517
|
+
resolverBaseUrl,
|
|
518
|
+
queued,
|
|
519
|
+
requestTimeoutMs,
|
|
520
|
+
);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (disposed) break;
|
|
523
|
+
const index = state.outcomeQueue.findIndex((item) => item === queued);
|
|
524
|
+
if (index < 0) continue;
|
|
525
|
+
const current = state.outcomeQueue[index]!;
|
|
526
|
+
const attempts = Math.min(current.attempts + 1, 100_000);
|
|
527
|
+
const next = {
|
|
528
|
+
...current,
|
|
529
|
+
attempts,
|
|
530
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(
|
|
531
|
+
current.eventId,
|
|
532
|
+
attempts,
|
|
533
|
+
retryBaseMs,
|
|
534
|
+
retryMaxMs,
|
|
535
|
+
),
|
|
536
|
+
};
|
|
537
|
+
state = {
|
|
538
|
+
...state,
|
|
539
|
+
outcomeQueue: state.outcomeQueue.map((item, itemIndex) => itemIndex === index ? next : item),
|
|
540
|
+
};
|
|
541
|
+
persistOutcomeState();
|
|
542
|
+
reportError(error);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (disposed) break;
|
|
547
|
+
const index = state.outcomeQueue.findIndex((item) => item === queued);
|
|
548
|
+
if (index < 0) continue;
|
|
549
|
+
const status = response.status;
|
|
550
|
+
if ((status >= 200 && status < 300) || status === 409 || status === 404 || status === 410) {
|
|
551
|
+
state = {
|
|
552
|
+
...state,
|
|
553
|
+
outcomeQueue: state.outcomeQueue.filter((item) => item !== queued),
|
|
554
|
+
};
|
|
555
|
+
persistOutcomeState();
|
|
556
|
+
if ((status >= 200 && status < 300) || status === 409) delivered += 1;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (status === 429 || status >= 500) {
|
|
561
|
+
const current = state.outcomeQueue[index]!;
|
|
562
|
+
const attempts = Math.min(current.attempts + 1, 100_000);
|
|
563
|
+
const next = {
|
|
564
|
+
...current,
|
|
565
|
+
attempts,
|
|
566
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(
|
|
567
|
+
current.eventId,
|
|
568
|
+
attempts,
|
|
569
|
+
retryBaseMs,
|
|
570
|
+
retryMaxMs,
|
|
571
|
+
),
|
|
572
|
+
};
|
|
573
|
+
state = {
|
|
574
|
+
...state,
|
|
575
|
+
outcomeQueue: state.outcomeQueue.map((item, itemIndex) => itemIndex === index ? next : item),
|
|
576
|
+
};
|
|
577
|
+
persistOutcomeState();
|
|
578
|
+
reportError(new Error(`Pulse Links: Encore outcome HTTP ${status}`));
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Other client/protocol errors cannot become valid by retrying the same payload.
|
|
583
|
+
state = {
|
|
584
|
+
...state,
|
|
585
|
+
outcomeQueue: state.outcomeQueue.filter((item) => item !== queued),
|
|
586
|
+
};
|
|
587
|
+
persistOutcomeState();
|
|
588
|
+
reportError(new Error(`Pulse Links: terminal Encore outcome HTTP ${status}`));
|
|
589
|
+
}
|
|
590
|
+
return delivered;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
let owned: Promise<number>;
|
|
594
|
+
owned = run().finally(() => {
|
|
595
|
+
if (outcomeSending === owned) outcomeSending = null;
|
|
596
|
+
const nextOutcome = state.outcomeQueue[0];
|
|
597
|
+
if (!disposed && nextOutcome && nextOutcome.nextRetryAt <= now()) {
|
|
598
|
+
void flushResolverOutcomes();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
outcomeSending = owned;
|
|
602
|
+
return owned;
|
|
603
|
+
};
|
|
604
|
+
|
|
478
605
|
const emitOutcome = (link: ResolvedDeferredLink<Action>, name: DeferredLinkOutcomeName): void => {
|
|
479
606
|
const transitionKey = stableOutcomeKey(link.id, name);
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
607
|
+
if (state.notifiedOutcomes.includes(transitionKey)) return;
|
|
608
|
+
|
|
609
|
+
const occurredAt = new Date(now()).toISOString();
|
|
610
|
+
const shouldReportToResolver = (options.reportResolverOutcomes ?? true) && isPublicToken(link.id);
|
|
611
|
+
const queued: QueuedResolverOutcome | null = shouldReportToResolver ? {
|
|
612
|
+
transitionKey,
|
|
613
|
+
token: link.id,
|
|
614
|
+
name,
|
|
615
|
+
eventId: makeOutcomeEventId(
|
|
616
|
+
options.randomUUID,
|
|
617
|
+
new Set(state.outcomeQueue.map((outcome) => outcome.eventId)),
|
|
618
|
+
),
|
|
619
|
+
occurredAt,
|
|
620
|
+
matchBasis: link.matchBasis,
|
|
621
|
+
confidence: link.confidence,
|
|
622
|
+
attempts: 0,
|
|
623
|
+
nextRetryAt: 0,
|
|
624
|
+
} : null;
|
|
625
|
+
|
|
626
|
+
// Persist the logical transition and complete Encore payload before either delivery path.
|
|
627
|
+
// The token remains only in this local record and in Encore's URL path.
|
|
628
|
+
state = {
|
|
629
|
+
...state,
|
|
630
|
+
notifiedOutcomes: [...state.notifiedOutcomes, transitionKey].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
631
|
+
outcomeQueue: queued
|
|
632
|
+
? [...state.outcomeQueue, queued].slice(-MAX_RESOLVER_OUTCOMES)
|
|
633
|
+
: state.outcomeQueue,
|
|
634
|
+
};
|
|
635
|
+
persistOutcomeState();
|
|
636
|
+
|
|
637
|
+
const event: DeferredLinkOutcomeEvent = {
|
|
638
|
+
name,
|
|
639
|
+
matchBasis: link.matchBasis,
|
|
640
|
+
confidence: link.confidence,
|
|
641
|
+
...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
|
|
642
|
+
...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
|
|
643
|
+
...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
|
|
644
|
+
occurredAt,
|
|
645
|
+
};
|
|
646
|
+
try {
|
|
647
|
+
const result = options.onOutcome?.(event);
|
|
648
|
+
if (result && typeof (result as Promise<void>).catch === 'function') {
|
|
649
|
+
void (result as Promise<void>).catch(reportError);
|
|
504
650
|
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
reportError(error);
|
|
505
653
|
}
|
|
506
654
|
|
|
507
|
-
if (
|
|
508
|
-
void postResolverOutcome(
|
|
509
|
-
fetcher,
|
|
510
|
-
resolverBaseUrl,
|
|
511
|
-
link,
|
|
512
|
-
name,
|
|
513
|
-
requestTimeoutMs,
|
|
514
|
-
).catch(reportError);
|
|
515
|
-
}
|
|
655
|
+
if (queued) void flushResolverOutcomes();
|
|
516
656
|
};
|
|
517
657
|
|
|
518
658
|
const applyResolved = async (
|
|
@@ -911,12 +1051,14 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
911
1051
|
state.pending?.nextRetryAt ?? 0,
|
|
912
1052
|
lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
|
|
913
1053
|
accountRetryAt,
|
|
1054
|
+
state.outcomeQueue[0]?.nextRetryAt ?? 0,
|
|
914
1055
|
].filter((value) => value > now());
|
|
915
1056
|
if (candidates.length === 0) return;
|
|
916
1057
|
const next = Math.min(...candidates);
|
|
917
1058
|
retryTimer = setTimeout(() => {
|
|
918
1059
|
retryTimer = null;
|
|
919
1060
|
void process();
|
|
1061
|
+
void flushResolverOutcomes();
|
|
920
1062
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
921
1063
|
void matchFirstOpen(lastFirstOpenContext);
|
|
922
1064
|
}
|
|
@@ -934,6 +1076,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
934
1076
|
}
|
|
935
1077
|
scheduleWake();
|
|
936
1078
|
void process();
|
|
1079
|
+
void flushResolverOutcomes();
|
|
937
1080
|
|
|
938
1081
|
return {
|
|
939
1082
|
capture,
|
|
@@ -1106,10 +1249,45 @@ function makeInstallAttemptId(custom?: () => string): string {
|
|
|
1106
1249
|
?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
|
|
1107
1250
|
if (candidate && INSTALL_ATTEMPT_ID.test(candidate)) return candidate.toLowerCase();
|
|
1108
1251
|
} catch { /* use the non-identity fallback below */ }
|
|
1252
|
+
return fallbackUuidV4();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function makeOutcomeEventId(custom?: () => string, existing: ReadonlySet<string> = new Set()): string {
|
|
1256
|
+
try {
|
|
1257
|
+
const candidate = custom?.()
|
|
1258
|
+
?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
|
|
1259
|
+
if (candidate && UUID_V4.test(candidate) && !existing.has(candidate.toLowerCase())) {
|
|
1260
|
+
return candidate.toLowerCase();
|
|
1261
|
+
}
|
|
1262
|
+
} catch { /* use the UUIDv4 fallback below */ }
|
|
1263
|
+
let fallback = fallbackUuidV4();
|
|
1264
|
+
while (existing.has(fallback)) fallback = fallbackUuidV4();
|
|
1265
|
+
return fallback;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function fallbackUuidV4(): string {
|
|
1109
1269
|
const hex = (): string => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
|
1110
1270
|
return `${hex()}${hex()}-${hex()}-4${hex().slice(1)}-${(8 + Math.floor(Math.random() * 4)).toString(16)}${hex().slice(1)}-${hex()}${hex()}${hex()}`;
|
|
1111
1271
|
}
|
|
1112
1272
|
|
|
1273
|
+
/** Deterministic equal jitter keeps a persisted retry stable while spreading different events. */
|
|
1274
|
+
function resolverOutcomeRetryDelay(
|
|
1275
|
+
eventId: string,
|
|
1276
|
+
attempts: number,
|
|
1277
|
+
retryBaseMs: number,
|
|
1278
|
+
retryMaxMs: number,
|
|
1279
|
+
): number {
|
|
1280
|
+
const exponent = Math.min(Math.max(attempts - 1, 0), 10);
|
|
1281
|
+
const ceiling = Math.min(retryMaxMs, retryBaseMs * 2 ** exponent);
|
|
1282
|
+
let hash = 0x811c9dc5;
|
|
1283
|
+
const seed = `${eventId}:${attempts}`;
|
|
1284
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
1285
|
+
hash = Math.imul(hash ^ seed.charCodeAt(index), 0x01000193) >>> 0;
|
|
1286
|
+
}
|
|
1287
|
+
const jitter = 0.5 + (hash / 0xffffffff) * 0.5;
|
|
1288
|
+
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1113
1291
|
async function withTimeout(
|
|
1114
1292
|
fetcher: typeof fetch,
|
|
1115
1293
|
input: string,
|
|
@@ -1125,27 +1303,90 @@ async function withTimeout(
|
|
|
1125
1303
|
}
|
|
1126
1304
|
}
|
|
1127
1305
|
|
|
1128
|
-
async function postResolverOutcome
|
|
1306
|
+
async function postResolverOutcome(
|
|
1129
1307
|
fetcher: typeof fetch,
|
|
1130
1308
|
resolverBaseUrl: string,
|
|
1131
|
-
|
|
1132
|
-
name: DeferredLinkOutcomeName,
|
|
1309
|
+
outcome: QueuedResolverOutcome,
|
|
1133
1310
|
timeoutMs: number,
|
|
1134
|
-
): Promise<
|
|
1135
|
-
|
|
1311
|
+
): Promise<Response> {
|
|
1312
|
+
return withTimeout(
|
|
1136
1313
|
fetcher,
|
|
1137
|
-
`${resolverBaseUrl}${encodeURIComponent(
|
|
1314
|
+
`${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`,
|
|
1138
1315
|
{
|
|
1139
1316
|
method: 'POST',
|
|
1140
1317
|
headers: {
|
|
1141
1318
|
'Content-Type': 'application/json',
|
|
1142
1319
|
},
|
|
1143
|
-
body: JSON.stringify({
|
|
1320
|
+
body: JSON.stringify({
|
|
1321
|
+
eventId: outcome.eventId,
|
|
1322
|
+
occurredAt: outcome.occurredAt,
|
|
1323
|
+
matchBasis: outcome.matchBasis,
|
|
1324
|
+
confidence: outcome.confidence,
|
|
1325
|
+
}),
|
|
1144
1326
|
},
|
|
1145
1327
|
timeoutMs,
|
|
1146
1328
|
);
|
|
1147
1329
|
}
|
|
1148
1330
|
|
|
1331
|
+
function readResolverOutcomeQueue(raw: unknown): QueuedResolverOutcome[] {
|
|
1332
|
+
if (!Array.isArray(raw)) return [];
|
|
1333
|
+
const queue: QueuedResolverOutcome[] = [];
|
|
1334
|
+
const seenTransitions = new Set<string>();
|
|
1335
|
+
const seenEvents = new Set<string>();
|
|
1336
|
+
const candidates = [...raw].reverse();
|
|
1337
|
+
for (const candidate of candidates) {
|
|
1338
|
+
if (queue.length >= MAX_RESOLVER_OUTCOMES) break;
|
|
1339
|
+
if (!candidate || typeof candidate !== 'object') continue;
|
|
1340
|
+
const value = candidate as Record<string, unknown>;
|
|
1341
|
+
const token = normalizeDeferredHandoffToken(value.token as string | undefined);
|
|
1342
|
+
const name = isDeferredLinkOutcomeName(value.name) ? value.name : null;
|
|
1343
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
|
|
1344
|
+
? value.eventId.toLowerCase()
|
|
1345
|
+
: null;
|
|
1346
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
1347
|
+
const matchBasis = typeof value.matchBasis === 'string'
|
|
1348
|
+
&& MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
|
|
1349
|
+
? value.matchBasis as DeferredLinkMatchBasis
|
|
1350
|
+
: null;
|
|
1351
|
+
if (!token || !name || !eventId || !occurredAt || !matchBasis) continue;
|
|
1352
|
+
const transitionKey = stableOutcomeKey(token, name);
|
|
1353
|
+
if (value.transitionKey !== transitionKey
|
|
1354
|
+
|| seenTransitions.has(transitionKey)
|
|
1355
|
+
|| seenEvents.has(eventId)) continue;
|
|
1356
|
+
const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence)
|
|
1357
|
+
? clampConfidence(value.confidence, 0)
|
|
1358
|
+
: null;
|
|
1359
|
+
if (confidence === null) continue;
|
|
1360
|
+
seenTransitions.add(transitionKey);
|
|
1361
|
+
seenEvents.add(eventId);
|
|
1362
|
+
queue.unshift({
|
|
1363
|
+
transitionKey,
|
|
1364
|
+
token,
|
|
1365
|
+
name,
|
|
1366
|
+
eventId,
|
|
1367
|
+
occurredAt,
|
|
1368
|
+
matchBasis,
|
|
1369
|
+
confidence,
|
|
1370
|
+
attempts: safeInteger(value.attempts, 100_000),
|
|
1371
|
+
nextRetryAt: safeTimestamp(value.nextRetryAt),
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return queue;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function isDeferredLinkOutcomeName(value: unknown): value is DeferredLinkOutcomeName {
|
|
1378
|
+
return value === 'app_open_confirmed'
|
|
1379
|
+
|| value === 'deferred_link_resolved'
|
|
1380
|
+
|| value === 'action_applied';
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function normalizeOccurredAt(value: unknown): string | null {
|
|
1384
|
+
if (typeof value !== 'string' || value.length > 40) return null;
|
|
1385
|
+
const timestamp = Date.parse(value);
|
|
1386
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
1387
|
+
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1149
1390
|
function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
|
|
1150
1391
|
if (!storage) return emptyState();
|
|
1151
1392
|
try {
|
|
@@ -1192,13 +1433,18 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
|
|
|
1192
1433
|
&& /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value))
|
|
1193
1434
|
.slice(-MAX_NOTIFIED_OUTCOMES)
|
|
1194
1435
|
: [];
|
|
1436
|
+
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
1195
1437
|
return {
|
|
1196
1438
|
version: 1,
|
|
1197
1439
|
status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
|
|
1198
1440
|
pending,
|
|
1199
1441
|
lastAppliedId: lastApplied,
|
|
1200
1442
|
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1201
|
-
notifiedOutcomes: [...new Set(
|
|
1443
|
+
notifiedOutcomes: [...new Set([
|
|
1444
|
+
...notifiedOutcomes,
|
|
1445
|
+
...outcomeQueue.map((outcome) => outcome.transitionKey),
|
|
1446
|
+
])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1447
|
+
outcomeQueue,
|
|
1202
1448
|
firstOpen: {
|
|
1203
1449
|
installAttemptId: attemptId,
|
|
1204
1450
|
completed: firstValue.completed === true,
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
#import <React/RCTBridgeModule.h>
|
|
2
|
-
|
|
3
|
-
/// Cross-platform native capability marker. Android supplies Play Install Referrer through an
|
|
4
|
-
/// optional reflected dependency; iOS deterministic recovery uses Universal Links/UIPasteControl.
|
|
5
|
-
@interface PulseAttribution : NSObject <RCTBridgeModule>
|
|
6
|
-
@end
|
|
7
|
-
|
|
8
|
-
@implementation PulseAttribution
|
|
9
|
-
|
|
10
|
-
RCT_EXPORT_MODULE(PulseAttribution)
|
|
11
|
-
|
|
12
|
-
+ (BOOL)requiresMainQueueSetup
|
|
13
|
-
{
|
|
14
|
-
return NO;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
RCT_REMAP_METHOD(getDeferredHandoff,
|
|
18
|
-
getDeferredHandoffWithResolver:(RCTPromiseResolveBlock)resolve
|
|
19
|
-
rejecter:(RCTPromiseRejectBlock)reject)
|
|
20
|
-
{
|
|
21
|
-
resolve(@{
|
|
22
|
-
@"status": @"FEATURE_NOT_SUPPORTED",
|
|
23
|
-
@"retryable": @NO,
|
|
24
|
-
@"attempts": @0,
|
|
25
|
-
@"token": [NSNull null],
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
@end
|