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/README.md
CHANGED
|
@@ -138,7 +138,11 @@ On a recent iOS first open, the host may call `links.matchFirstOpen` with its na
|
|
|
138
138
|
bundle id and locale. It may also provide bounded ephemeral release/model dimensions (app/OS
|
|
139
139
|
version, hardware model code, device type, distribution, timezone offset and emulator flag). Pulse
|
|
140
140
|
validates and omits malformed values. The request intentionally contains no unique device id,
|
|
141
|
-
IDFV/IDFA,
|
|
141
|
+
IDFV/IDFA, clipboard value or client-observed IP. It carries the signed-in address only when the
|
|
142
|
+
integrator sets `sendAccountEmailOnFirstOpen` and the host passes one: an install that names its
|
|
143
|
+
account is bound to the message sent there instead of being inferred, which is the only
|
|
144
|
+
deterministic signal left where no store referrer survives. The switch is off by default, so
|
|
145
|
+
reading your own auth state never starts forwarding addresses by itself. Ambiguous matches produce no action.
|
|
142
146
|
Sensitive actions are rejected for every probabilistic result even if a malformed server response
|
|
143
147
|
claims otherwise. A random install-attempt nonce may accompany later deterministic resolver
|
|
144
148
|
outcomes so the server can calibrate its earlier probabilistic decision against deterministic
|
|
@@ -157,6 +157,29 @@ class PulseController private constructor() {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Which binary this is — the versionCode, as a string.
|
|
162
|
+
*
|
|
163
|
+
* The runtime version is versionName, and several versionCodes ship under one. Where bundles
|
|
164
|
+
* are wrapped for integrity that means several keys behind one runtime version, and a bundle
|
|
165
|
+
* wrapped for another build cannot be opened: the app does not degrade, it stops starting. This
|
|
166
|
+
* is what lets the server keep those bundles apart. Null when the package cannot be read, in
|
|
167
|
+
* which case the header is omitted and the server serves as it always did.
|
|
168
|
+
*/
|
|
169
|
+
internal val nativeBuildNumber: String?
|
|
170
|
+
get() = try {
|
|
171
|
+
val ctx = context
|
|
172
|
+
if (ctx == null) null else {
|
|
173
|
+
val info = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
174
|
+
@Suppress("DEPRECATION")
|
|
175
|
+
val code = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P)
|
|
176
|
+
info.longVersionCode else info.versionCode.toLong()
|
|
177
|
+
code.toString()
|
|
178
|
+
}
|
|
179
|
+
} catch (e: Exception) {
|
|
180
|
+
null
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
/**
|
|
161
184
|
* Load config from AndroidManifest.xml metadata (like expo-updates)
|
|
162
185
|
*/
|
|
@@ -1339,6 +1362,12 @@ object PulseRemoteLoader {
|
|
|
1339
1362
|
// Stable device id so the server can bucket this device into staged rollouts (without it,
|
|
1340
1363
|
// a device is held back from any partial rollout until it reaches 100%).
|
|
1341
1364
|
connection.setRequestProperty("Pulse-Device-Id", PulseController.getInstance().deviceId)
|
|
1365
|
+
// WHICH binary is asking. The runtime version is versionName, and several versionCodes
|
|
1366
|
+
// can ship under one — so where bundles are wrapped for integrity, one runtime version
|
|
1367
|
+
// can mean several keys, and a bundle wrapped for another build cannot be opened at all.
|
|
1368
|
+
// versionCode is the number that always moves, so the server can keep a bundle prepared
|
|
1369
|
+
// for one build away from the others. Servers that do not know the header ignore it.
|
|
1370
|
+
PulseController.getInstance().nativeBuildNumber?.let { connection.setRequestProperty("Pulse-Native-Build", it) }
|
|
1342
1371
|
|
|
1343
1372
|
pulseLog(TAG, "checkForUpdate: url=${config.updateUrl}")
|
|
1344
1373
|
pulseLog(TAG, "checkForUpdate: runtimeVersion=${config.runtimeVersion}")
|
|
@@ -1364,6 +1364,16 @@ final class PulseRemoteLoader {
|
|
|
1364
1364
|
// Stable device id so the server can bucket this device into staged rollouts (without it,
|
|
1365
1365
|
// a device is held back from any partial rollout until it reaches 100%).
|
|
1366
1366
|
request.setValue(PulseController.shared.deviceId, forHTTPHeaderField: "Pulse-Device-Id")
|
|
1367
|
+
// WHICH binary is asking. The runtime version does not answer that on iOS: it is
|
|
1368
|
+
// MARKETING_VERSION, which stays put across re-submissions, so one runtime version can be
|
|
1369
|
+
// several builds. Where bundles are wrapped for integrity each build holds its own key, and
|
|
1370
|
+
// a bundle wrapped for another one cannot be opened — the app does not degrade, it stops
|
|
1371
|
+
// starting. CFBundleVersion is the number that does move, so the server can keep a bundle
|
|
1372
|
+
// prepared for one build away from the others. A server that does not know the header
|
|
1373
|
+
// ignores it, and a release not tied to a build is still served to everyone.
|
|
1374
|
+
if let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String, !build.isEmpty {
|
|
1375
|
+
request.setValue(build, forHTTPHeaderField: "Pulse-Native-Build")
|
|
1376
|
+
}
|
|
1367
1377
|
|
|
1368
1378
|
if let channel = config.channel {
|
|
1369
1379
|
request.setValue(channel, forHTTPHeaderField: "X-Pulse-Channel-Name")
|
package/lib/commonjs/links.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.createDeferredLinkClient = void 0;
|
|
|
7
7
|
exports.createPulseLinkClient = createPulseLinkClient;
|
|
8
8
|
exports.extractDeferredHandoffToken = extractDeferredHandoffToken;
|
|
9
9
|
exports.isOpaqueDeferredLinkToken = isOpaqueDeferredLinkToken;
|
|
10
|
+
exports.normalizeAccountEmail = normalizeAccountEmail;
|
|
10
11
|
exports.normalizeAndroidInstallReferrerResult = normalizeAndroidInstallReferrerResult;
|
|
11
12
|
exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
|
|
12
13
|
/**
|
|
@@ -195,6 +196,11 @@ function createPulseLinkClient(options) {
|
|
|
195
196
|
let accountRetryAt = 0;
|
|
196
197
|
let accountAttempts = 0;
|
|
197
198
|
let deterministicEpoch = 0;
|
|
199
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
200
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
201
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
202
|
+
// state write, retry and outcome from the old instance.
|
|
203
|
+
let lifecycleGeneration = 0;
|
|
198
204
|
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
199
205
|
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
200
206
|
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
@@ -413,6 +419,9 @@ function createPulseLinkClient(options) {
|
|
|
413
419
|
return acknowledgeTerminalDelivery(tombstone);
|
|
414
420
|
};
|
|
415
421
|
const completeFirstOpen = (status, outcome) => {
|
|
422
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
423
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
424
|
+
const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
|
|
416
425
|
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
417
426
|
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
418
427
|
try {
|
|
@@ -421,6 +430,9 @@ function createPulseLinkClient(options) {
|
|
|
421
430
|
shouldQueue = false;
|
|
422
431
|
reportError(error);
|
|
423
432
|
}
|
|
433
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
434
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
435
|
+
if (!completionIsCurrent()) return;
|
|
424
436
|
}
|
|
425
437
|
if (!shouldQueue) {
|
|
426
438
|
state = {
|
|
@@ -464,8 +476,10 @@ function createPulseLinkClient(options) {
|
|
|
464
476
|
void flushTerminalDelivery();
|
|
465
477
|
};
|
|
466
478
|
const blockFirstOpenForDeterministic = () => {
|
|
479
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
467
480
|
deterministicEpoch += 1;
|
|
468
|
-
|
|
481
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
482
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
469
483
|
state = {
|
|
470
484
|
...state,
|
|
471
485
|
firstOpen: {
|
|
@@ -475,14 +489,23 @@ function createPulseLinkClient(options) {
|
|
|
475
489
|
}
|
|
476
490
|
};
|
|
477
491
|
persistAndNotify();
|
|
492
|
+
return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
|
|
478
493
|
};
|
|
479
|
-
const
|
|
494
|
+
const readAccountReadiness = () => {
|
|
495
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
496
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
497
|
+
let ready = false;
|
|
480
498
|
try {
|
|
481
|
-
|
|
499
|
+
ready = options.isAccountReady?.() ?? false;
|
|
482
500
|
} catch (error) {
|
|
483
501
|
reportError(error);
|
|
484
|
-
return false;
|
|
485
502
|
}
|
|
503
|
+
return {
|
|
504
|
+
ready,
|
|
505
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
506
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
507
|
+
current: !disposed && lifecycleGeneration === readinessLifecycleGeneration && deterministicEpoch === readinessDeterministicEpoch
|
|
508
|
+
};
|
|
486
509
|
};
|
|
487
510
|
const schedulePendingRetry = () => {
|
|
488
511
|
const pending = state.pending;
|
|
@@ -690,7 +713,9 @@ function createPulseLinkClient(options) {
|
|
|
690
713
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
691
714
|
return false;
|
|
692
715
|
}
|
|
693
|
-
|
|
716
|
+
const accountReadiness = readAccountReadiness();
|
|
717
|
+
if (!accountReadiness.current) return false;
|
|
718
|
+
if (!accountReadiness.ready) {
|
|
694
719
|
patchState({
|
|
695
720
|
status: 'waiting_for_account'
|
|
696
721
|
});
|
|
@@ -712,13 +737,23 @@ function createPulseLinkClient(options) {
|
|
|
712
737
|
let applicationFailed = false;
|
|
713
738
|
let applicationError;
|
|
714
739
|
const applicationEpoch = deterministicEpoch;
|
|
740
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
741
|
+
const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
|
|
715
742
|
const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
|
|
716
743
|
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
717
744
|
// applied does not represent another destination and must not leave that token pending.
|
|
718
745
|
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
719
746
|
routingApplicationInFlight = true;
|
|
720
747
|
emitOutcome(link, 'app_open_confirmed');
|
|
748
|
+
if (!lifecycleIsCurrent()) {
|
|
749
|
+
routingApplicationInFlight = false;
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
721
752
|
emitOutcome(link, 'deferred_link_resolved');
|
|
753
|
+
if (!lifecycleIsCurrent()) {
|
|
754
|
+
routingApplicationInFlight = false;
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
722
757
|
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
723
758
|
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
724
759
|
if (hasNewerPendingIntent()) {
|
|
@@ -741,10 +776,19 @@ function createPulseLinkClient(options) {
|
|
|
741
776
|
} finally {
|
|
742
777
|
routingApplicationInFlight = false;
|
|
743
778
|
}
|
|
779
|
+
|
|
780
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
781
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
782
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
783
|
+
if (!lifecycleIsCurrent()) return false;
|
|
744
784
|
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
745
785
|
if (applicationFailed) {
|
|
746
786
|
reportError(applicationError);
|
|
747
|
-
if (
|
|
787
|
+
if (!lifecycleIsCurrent()) return false;
|
|
788
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
789
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
790
|
+
// observed before diagnostics ran.
|
|
791
|
+
if (hasNewerPendingIntent()) {
|
|
748
792
|
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
749
793
|
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
750
794
|
void process();
|
|
@@ -779,11 +823,13 @@ function createPulseLinkClient(options) {
|
|
|
779
823
|
appliedIds
|
|
780
824
|
};
|
|
781
825
|
persistAndNotify();
|
|
826
|
+
if (!lifecycleIsCurrent()) return false;
|
|
782
827
|
if (link.matchBasis === 'account_bound') {
|
|
783
828
|
accountAttempts = 0;
|
|
784
829
|
accountRetryAt = 0;
|
|
785
830
|
}
|
|
786
831
|
emitOutcome(link, 'action_applied');
|
|
832
|
+
if (!lifecycleIsCurrent()) return false;
|
|
787
833
|
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
788
834
|
void process();
|
|
789
835
|
}
|
|
@@ -836,11 +882,13 @@ function createPulseLinkClient(options) {
|
|
|
836
882
|
return;
|
|
837
883
|
}
|
|
838
884
|
const token = pending.token;
|
|
885
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
839
886
|
patchState({
|
|
840
887
|
status: 'resolving'
|
|
841
888
|
});
|
|
889
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
842
890
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
843
|
-
if (disposed || state.pending?.token !== token) return;
|
|
891
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
844
892
|
if (publicResult.kind === 'resolved') {
|
|
845
893
|
await applyResolved(publicResult.link, token);
|
|
846
894
|
return;
|
|
@@ -849,7 +897,15 @@ function createPulseLinkClient(options) {
|
|
|
849
897
|
schedulePendingRetry();
|
|
850
898
|
return;
|
|
851
899
|
}
|
|
852
|
-
if (!options.accountBridge
|
|
900
|
+
if (!options.accountBridge) {
|
|
901
|
+
patchState({
|
|
902
|
+
status: 'waiting_for_account'
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const accountReadiness = readAccountReadiness();
|
|
907
|
+
if (!accountReadiness.current || disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
908
|
+
if (!accountReadiness.ready) {
|
|
853
909
|
patchState({
|
|
854
910
|
status: 'waiting_for_account'
|
|
855
911
|
});
|
|
@@ -857,7 +913,7 @@ function createPulseLinkClient(options) {
|
|
|
857
913
|
}
|
|
858
914
|
try {
|
|
859
915
|
const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
|
|
860
|
-
if (disposed || state.pending?.token !== token) return;
|
|
916
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
861
917
|
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
862
918
|
const claimed = normalizedClaim ? {
|
|
863
919
|
...normalizedClaim,
|
|
@@ -866,23 +922,28 @@ function createPulseLinkClient(options) {
|
|
|
866
922
|
confidence: 1
|
|
867
923
|
} : null;
|
|
868
924
|
if (claimed) {
|
|
869
|
-
blockFirstOpenForDeterministic();
|
|
925
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
870
926
|
await applyResolved(claimed, token);
|
|
871
927
|
} else clearPendingAsTerminal();
|
|
872
928
|
} catch (error) {
|
|
929
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
873
930
|
reportError(error);
|
|
874
|
-
if (state.pending?.token === token) schedulePendingRetry();
|
|
931
|
+
if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
|
|
875
932
|
}
|
|
876
933
|
return;
|
|
877
934
|
}
|
|
878
|
-
if (!options.accountBridge
|
|
935
|
+
if (!options.accountBridge) return;
|
|
936
|
+
const accountReadiness = readAccountReadiness();
|
|
937
|
+
if (!accountReadiness.current || state.pending) return;
|
|
938
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
879
939
|
const accountPendingEpoch = deterministicEpoch;
|
|
940
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
880
941
|
try {
|
|
881
942
|
const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
|
|
882
943
|
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
883
944
|
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
884
945
|
// captured token; otherwise both deterministic destinations could be applied.
|
|
885
|
-
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
946
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
886
947
|
if (!raw) {
|
|
887
948
|
accountAttempts = 0;
|
|
888
949
|
accountRetryAt = 0;
|
|
@@ -905,7 +966,7 @@ function createPulseLinkClient(options) {
|
|
|
905
966
|
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
906
967
|
// callback. This also covers an already-applied account item restored from older SDK
|
|
907
968
|
// state, which must still prevent a second probabilistic destination.
|
|
908
|
-
blockFirstOpenForDeterministic();
|
|
969
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
909
970
|
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
910
971
|
await applyResolved(pendingLink, null);
|
|
911
972
|
} else if (state.status !== 'applied') {
|
|
@@ -915,8 +976,9 @@ function createPulseLinkClient(options) {
|
|
|
915
976
|
}
|
|
916
977
|
}
|
|
917
978
|
} catch (error) {
|
|
918
|
-
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
979
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
919
980
|
reportError(error);
|
|
981
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
920
982
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
921
983
|
scheduleAccountRetry();
|
|
922
984
|
}
|
|
@@ -1019,7 +1081,14 @@ function createPulseLinkClient(options) {
|
|
|
1019
1081
|
lastFirstOpenContext = context;
|
|
1020
1082
|
if (state.pending) return 'deterministic_pending';
|
|
1021
1083
|
if (state.firstOpen.completed) return 'already_completed';
|
|
1022
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1084
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1085
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1086
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1087
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1088
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1089
|
+
scheduleWake();
|
|
1090
|
+
return 'backoff';
|
|
1091
|
+
}
|
|
1023
1092
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1024
1093
|
|
|
1025
1094
|
// The client starts account recovery at construction. Join that deterministic rail before
|
|
@@ -1028,13 +1097,20 @@ function createPulseLinkClient(options) {
|
|
|
1028
1097
|
await process();
|
|
1029
1098
|
if (disposed) return 'ineligible';
|
|
1030
1099
|
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1031
|
-
if (options.accountBridge
|
|
1032
|
-
|
|
1033
|
-
|
|
1100
|
+
if (options.accountBridge) {
|
|
1101
|
+
const accountReadiness = readAccountReadiness();
|
|
1102
|
+
if (!accountReadiness.current) {
|
|
1103
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1104
|
+
}
|
|
1105
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1106
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1107
|
+
return 'retry_scheduled';
|
|
1108
|
+
}
|
|
1034
1109
|
}
|
|
1035
1110
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1036
1111
|
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
1037
1112
|
const startingDeterministicEpoch = deterministicEpoch;
|
|
1113
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
1038
1114
|
state = {
|
|
1039
1115
|
...state,
|
|
1040
1116
|
status: 'resolving',
|
|
@@ -1044,14 +1120,44 @@ function createPulseLinkClient(options) {
|
|
|
1044
1120
|
attemptedAt: now()
|
|
1045
1121
|
}
|
|
1046
1122
|
};
|
|
1047
|
-
persistAndNotify();
|
|
1123
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1124
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1125
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1126
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1127
|
+
if (disposed) return 'ineligible';
|
|
1128
|
+
if (lifecycleGeneration !== startingLifecycleGeneration || deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1129
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1130
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1131
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1132
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1133
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1134
|
+
// continue normally.
|
|
1135
|
+
state = {
|
|
1136
|
+
...state,
|
|
1137
|
+
status: 'retryable_error',
|
|
1138
|
+
firstOpen: {
|
|
1139
|
+
...state.firstOpen,
|
|
1140
|
+
nextRetryAt: now() + retryBaseMs
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1144
|
+
notifyState();
|
|
1145
|
+
return 'retry_scheduled';
|
|
1146
|
+
}
|
|
1147
|
+
const accountEmail = options.sendAccountEmailOnFirstOpen === true ? normalizeAccountEmail(context.accountEmail) : null;
|
|
1048
1148
|
const body = {
|
|
1049
1149
|
appBundleId: context.appBundleId.trim(),
|
|
1050
1150
|
platform: 'ios',
|
|
1051
1151
|
locale: normalizeLocale(context.locale),
|
|
1052
1152
|
firstOpen: true,
|
|
1053
1153
|
installAttemptId,
|
|
1054
|
-
...normalizeAnonymousFirstOpenSignals(context)
|
|
1154
|
+
...normalizeAnonymousFirstOpenSignals(context),
|
|
1155
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1156
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1157
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1158
|
+
...(accountEmail ? {
|
|
1159
|
+
accountEmail
|
|
1160
|
+
} : {})
|
|
1055
1161
|
};
|
|
1056
1162
|
try {
|
|
1057
1163
|
const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
|
|
@@ -1177,6 +1283,8 @@ function createPulseLinkClient(options) {
|
|
|
1177
1283
|
return owned;
|
|
1178
1284
|
};
|
|
1179
1285
|
const captureAndroidInstallReferrer = async bridge => {
|
|
1286
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1287
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
1180
1288
|
let raw;
|
|
1181
1289
|
try {
|
|
1182
1290
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -1189,6 +1297,7 @@ function createPulseLinkClient(options) {
|
|
|
1189
1297
|
};
|
|
1190
1298
|
}
|
|
1191
1299
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1300
|
+
if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
1192
1301
|
if (result.status === 'OK' && result.token) {
|
|
1193
1302
|
capture(result.token, 'android_install_referrer');
|
|
1194
1303
|
}
|
|
@@ -1245,6 +1354,7 @@ function createPulseLinkClient(options) {
|
|
|
1245
1354
|
dispose: () => {
|
|
1246
1355
|
if (disposed) return;
|
|
1247
1356
|
disposed = true;
|
|
1357
|
+
lifecycleGeneration += 1;
|
|
1248
1358
|
deterministicEpoch += 1;
|
|
1249
1359
|
terminalDeliveryGeneration += 1;
|
|
1250
1360
|
if (retryTimer) clearTimeout(retryTimer);
|
|
@@ -1256,6 +1366,7 @@ function createPulseLinkClient(options) {
|
|
|
1256
1366
|
reset: () => {
|
|
1257
1367
|
accountRetryAt = 0;
|
|
1258
1368
|
accountAttempts = 0;
|
|
1369
|
+
lifecycleGeneration += 1;
|
|
1259
1370
|
deterministicEpoch += 1;
|
|
1260
1371
|
terminalDeliveryGeneration += 1;
|
|
1261
1372
|
terminalDeliverySending = null;
|
|
@@ -1456,6 +1567,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
|
|
|
1456
1567
|
const jitter = 0.5 + hash / 0xffffffff * 0.5;
|
|
1457
1568
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1458
1569
|
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
1573
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
1574
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
1575
|
+
*/
|
|
1576
|
+
function normalizeAccountEmail(raw) {
|
|
1577
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
1578
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
1579
|
+
const at = value.indexOf('@');
|
|
1580
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
1581
|
+
if (/\s/.test(value)) return null;
|
|
1582
|
+
return value;
|
|
1583
|
+
}
|
|
1459
1584
|
async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
1460
1585
|
const controller = new AbortController();
|
|
1461
1586
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|