@fixback/expo 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +5 -4
- package/dist/adapters.js.map +1 -1
- package/dist/breadcrumbs.d.ts +15 -51
- package/dist/breadcrumbs.js +22 -169
- package/dist/breadcrumbs.js.map +1 -1
- package/dist/client-boot.d.ts +40 -0
- package/dist/client-boot.js +269 -0
- package/dist/client-boot.js.map +1 -0
- package/dist/client-connect.d.ts +33 -0
- package/dist/client-connect.js +306 -0
- package/dist/client-connect.js.map +1 -0
- package/dist/client-replay.d.ts +31 -0
- package/dist/client-replay.js +165 -0
- package/dist/client-replay.js.map +1 -0
- package/dist/client-state.d.ts +131 -0
- package/dist/client-state.js +158 -0
- package/dist/client-state.js.map +1 -0
- package/dist/client.d.ts +10 -253
- package/dist/client.js +73 -865
- package/dist/client.js.map +1 -1
- package/dist/error-capture.js +6 -26
- package/dist/error-capture.js.map +1 -1
- package/dist/identity.d.ts +0 -2
- package/dist/identity.js +0 -3
- package/dist/identity.js.map +1 -1
- package/dist/options.d.ts +263 -0
- package/dist/options.js +12 -0
- package/dist/options.js.map +1 -0
- package/dist/report.d.ts +13 -12
- package/dist/report.js +19 -38
- package/dist/report.js.map +1 -1
- package/dist/submit.d.ts +10 -46
- package/dist/submit.js +14 -79
- package/dist/submit.js.map +1 -1
- package/dist/tokens.d.ts +5 -3
- package/dist/tokens.js +8 -6
- package/dist/tokens.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
package/dist/client.js
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* `resolveIdentity`, then `armCapture` — rather than one long procedure, so the
|
|
14
14
|
* lifecycle reads as what it is and each step is testable on its own.
|
|
15
15
|
*
|
|
16
|
+
* The client is a few pieces over one shared state object (`./client-state`, #661):
|
|
17
|
+
* boot and the capture it arms (`./client-boot`), mobile replay's arming
|
|
18
|
+
* (`./client-replay`), and Connect (`./client-connect`). What stays here is the
|
|
19
|
+
* lifecycle (`start`, `destroy`) and the composer-facing surface (`present`,
|
|
20
|
+
* `submit`, `trackScreen`). The public shapes live in `./options` and are
|
|
21
|
+
* re-exported from here.
|
|
22
|
+
*
|
|
16
23
|
* Every platform touchpoint is an injected adapter (`FixbackAdapters`), so the
|
|
17
24
|
* whole orchestration is unit-tested without React Native. Every path is
|
|
18
25
|
* defensive: a Fixback problem leaves the host app untouched — `start` resolves
|
|
@@ -23,199 +30,32 @@ exports.REPLAY_BUCKET_MS = exports.DEFAULT_REPLAY_MAX_EDGE = exports.DEFAULT_REP
|
|
|
23
30
|
exports.createFixbackClient = createFixbackClient;
|
|
24
31
|
const sdk_core_1 = require("@fixback/sdk-core");
|
|
25
32
|
Object.defineProperty(exports, "DEFAULT_API_URL", { enumerable: true, get: function () { return sdk_core_1.DEFAULT_API_URL; } });
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
33
|
+
const client_boot_1 = require("./client-boot");
|
|
34
|
+
const client_connect_1 = require("./client-connect");
|
|
35
|
+
const client_replay_1 = require("./client-replay");
|
|
36
|
+
const client_state_1 = require("./client-state");
|
|
30
37
|
const identity_1 = require("./identity");
|
|
31
38
|
const origin_1 = require("./origin");
|
|
32
39
|
Object.defineProperty(exports, "canonicalizeOrigin", { enumerable: true, get: function () { return origin_1.canonicalizeOrigin; } });
|
|
33
|
-
const propagation_1 = require("./propagation");
|
|
34
40
|
const report_1 = require("./report");
|
|
35
|
-
const
|
|
36
|
-
const replay_encode_1 = require("./replay-encode");
|
|
41
|
+
const reporter_session_store_1 = require("./reporter-session-store");
|
|
37
42
|
const shake_1 = require("./shake");
|
|
38
43
|
const submit_1 = require("./submit");
|
|
39
44
|
const version_1 = require("./version");
|
|
40
|
-
/**
|
|
41
|
-
* How long the browser door's `signIn()` waits for the connect page's redirect to come
|
|
42
|
-
* back as a deep link (ADR-0052). Generous, because the round trip contains a real
|
|
43
|
-
* sign-in — the doors, an email link, a password manager — and the only cost of
|
|
44
|
-
* waiting is one pending promise. It exists so a person who abandons the page settles
|
|
45
|
-
* that promise eventually rather than never; the Connect itself is not cancelled by it
|
|
46
|
-
* and still lands through the inbound listener if they come back later.
|
|
47
|
-
*/
|
|
48
|
-
const CONNECT_BROWSER_WAIT_MS = 5 * 60_000;
|
|
49
45
|
var shake_2 = require("./shake");
|
|
50
46
|
Object.defineProperty(exports, "DEFAULT_SHAKE_SAMPLE_INTERVAL_MS", { enumerable: true, get: function () { return shake_2.DEFAULT_SHAKE_SAMPLE_INTERVAL_MS; } });
|
|
51
|
-
var
|
|
52
|
-
Object.defineProperty(exports, "DEFAULT_REPLAY_FPS", { enumerable: true, get: function () { return
|
|
53
|
-
Object.defineProperty(exports, "DEFAULT_REPLAY_MAX_EDGE", { enumerable: true, get: function () { return
|
|
54
|
-
Object.defineProperty(exports, "REPLAY_BUCKET_MS", { enumerable: true, get: function () { return
|
|
55
|
-
/** Normalise a base URL: trim and drop trailing slashes. */
|
|
56
|
-
function normalizeBase(value) {
|
|
57
|
-
return value.trim().replace(/\/+$/, "");
|
|
58
|
-
}
|
|
59
|
-
/** Turn a screen name into the path segment of the report `url`. */
|
|
60
|
-
function screenPath(name) {
|
|
61
|
-
return name.trim().replace(/^\/+/, "");
|
|
62
|
-
}
|
|
47
|
+
var replay_1 = require("./replay");
|
|
48
|
+
Object.defineProperty(exports, "DEFAULT_REPLAY_FPS", { enumerable: true, get: function () { return replay_1.DEFAULT_REPLAY_FPS; } });
|
|
49
|
+
Object.defineProperty(exports, "DEFAULT_REPLAY_MAX_EDGE", { enumerable: true, get: function () { return replay_1.DEFAULT_REPLAY_MAX_EDGE; } });
|
|
50
|
+
Object.defineProperty(exports, "REPLAY_BUCKET_MS", { enumerable: true, get: function () { return replay_1.REPLAY_BUCKET_MS; } });
|
|
63
51
|
/** Create the headless client. See the module doc. */
|
|
64
52
|
function createFixbackClient(options, adapters = {}) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
// The host app's Release (#117, ADR-0024), validated once here: an invalid value
|
|
72
|
-
// is dropped with a warning rather than shipped for the server to discard — and
|
|
73
|
-
// never a reason to refuse to start (capture must not break on config).
|
|
74
|
-
const release = options.release ? (0, sdk_core_1.normaliseRelease)(options.release) : undefined;
|
|
75
|
-
if (options.release && !release) {
|
|
76
|
-
warn(`Fixback: ignoring invalid release ${JSON.stringify(options.release)} — use 1-100 ` +
|
|
77
|
-
"visible characters with no whitespace or slashes (e.g. a git SHA or semver).");
|
|
78
|
-
}
|
|
79
|
-
// `environment` is no longer an option — the key selects the Environment (ADR
|
|
80
|
-
// 2026-09-24) — but a plain-JS caller gets no type error for passing it, and would
|
|
81
|
-
// otherwise believe it was honoured. Said once, here, never per report.
|
|
82
|
-
const removedOption = (0, sdk_core_1.removedEnvironmentOptionWarning)(options);
|
|
83
|
-
if (removedOption)
|
|
84
|
-
warn(`Fixback: ${removedOption}`);
|
|
85
|
-
// The Correlation id's targets (#540) — app topology known in code, so an init option
|
|
86
|
-
// rather than a served setting, and allowlist-only on mobile. An entry that is not a
|
|
87
|
-
// bare origin is never used, and said so once, the way an invalid release is — in the
|
|
88
|
-
// words every capture SDK shares.
|
|
89
|
-
const propagation = (0, propagation_1.normalizePropagateTo)(options.propagateTo);
|
|
90
|
-
const ignoredPropagateTo = (0, sdk_core_1.ignoredPropagateToWarning)(propagation.rejected);
|
|
91
|
-
if (ignoredPropagateTo)
|
|
92
|
-
warn(`Fixback: ${ignoredPropagateTo}`);
|
|
93
|
-
const buffer = (0, sdk_core_1.createBreadcrumbBuffer)({
|
|
94
|
-
beforeBreadcrumb: options.beforeBreadcrumb,
|
|
95
|
-
now,
|
|
96
|
-
});
|
|
97
|
-
let status = "idle";
|
|
98
|
-
let boot = null;
|
|
99
|
-
/** Monotonic id of the most recently *started* boot — see {@link bootAndArm}. */
|
|
100
|
-
let bootSequence = 0;
|
|
101
|
-
let currentScreen = null;
|
|
102
|
-
let teardownTrace = null;
|
|
103
|
-
let autoCapture = null;
|
|
104
|
-
/**
|
|
105
|
-
* The mobile replay recorder, and the window frozen when the composer opened
|
|
106
|
-
* (ADR-0044). Both are null unless the Project enabled replay *and* this app asked
|
|
107
|
-
* for it — recording an unmasked screen is never something the SDK starts on its own.
|
|
108
|
-
*/
|
|
109
|
-
let replayRecorder = null;
|
|
110
|
-
let pendingReplayWindow = null;
|
|
111
|
-
let identity = {};
|
|
112
|
-
// Connect state (ADR-0066): the Reporter session token held after a Connect, the
|
|
113
|
-
// signed-in Account and its live tier (for the composer's identity row), and where the
|
|
114
|
-
// SDK opens Connect — all set from the boot answer, refreshed on every boot.
|
|
115
|
-
let reporterSessionToken = null;
|
|
116
|
-
let account = null;
|
|
117
|
-
let currentTier = null;
|
|
118
|
-
let connectUrl;
|
|
119
|
-
/** Removes the inbound deep-link listener; re-armed by each `start()`. */
|
|
120
|
-
let teardownLinking = null;
|
|
121
|
-
/**
|
|
122
|
-
* The last inbound Connect code redeemed, so a replayed launch URL is not exchanged
|
|
123
|
-
* twice. A code is single-use: the second attempt would fail, and a failed exchange
|
|
124
|
-
* is indistinguishable from a Connect that never worked.
|
|
125
|
-
*/
|
|
126
|
-
let lastInboundCode = null;
|
|
127
|
-
/**
|
|
128
|
-
* Callers waiting for the next inbound Connect to land — the browser door's
|
|
129
|
-
* `signIn()` (ADR-0052), which hands control to the connect page and gets it back as
|
|
130
|
-
* a deep link the inbound listener redeems. Resolved with `true` by a successful
|
|
131
|
-
* redemption, `false` when the wait gives up or the client is destroyed, so an
|
|
132
|
-
* awaited `signIn()` reports the identity the round trip actually produced instead
|
|
133
|
-
* of the anonymous one it started from.
|
|
134
|
-
*/
|
|
135
|
-
let inboundWaiters = [];
|
|
136
|
-
/**
|
|
137
|
-
* The lifecycle generation. Each `start()` claims a new generation and every
|
|
138
|
-
* await inside it re-checks that it is still current; `destroy()` bumps the
|
|
139
|
-
* generation so an in-flight start abandons itself. A destroyed client can be
|
|
140
|
-
* started again — React 18+ StrictMode (and Fast Refresh) replay the
|
|
141
|
-
* provider's effect as mount → cleanup → re-mount on the same client
|
|
142
|
-
* instance, so destroy must not be terminal.
|
|
143
|
-
*/
|
|
144
|
-
let generation = 0;
|
|
145
|
-
/** Whether a start() has claimed the current generation. */
|
|
146
|
-
let running = false;
|
|
147
|
-
const statusListeners = new Set();
|
|
148
|
-
const presentListeners = new Set();
|
|
149
|
-
const identityListeners = new Set();
|
|
150
|
-
const maintenanceListeners = new Set();
|
|
151
|
-
/** Maintenance mode's notice — boot's, or a held report's — else `null`. */
|
|
152
|
-
let maintenance = null;
|
|
153
|
-
/** The next boot while the notice shows. */
|
|
154
|
-
let maintenanceRecheck = null;
|
|
155
|
-
function setMaintenance(next) {
|
|
156
|
-
const changed = (maintenance === null) !== (next === null) ||
|
|
157
|
-
maintenance?.message !== next?.message ||
|
|
158
|
-
maintenance?.until !== next?.until;
|
|
159
|
-
maintenance = next;
|
|
160
|
-
if (next !== null)
|
|
161
|
-
scheduleMaintenanceRecheck();
|
|
162
|
-
if (!changed)
|
|
163
|
-
return;
|
|
164
|
-
for (const listener of maintenanceListeners) {
|
|
165
|
-
try {
|
|
166
|
-
listener(next);
|
|
167
|
-
}
|
|
168
|
-
catch {
|
|
169
|
-
/* a listener must never break the client */
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
/** Ask boot again in a minute, once, so the notice comes down on its own (the maintenance-mode ADR). */
|
|
174
|
-
function scheduleMaintenanceRecheck() {
|
|
175
|
-
if (maintenanceRecheck !== null || !running)
|
|
176
|
-
return;
|
|
177
|
-
const gen = generation;
|
|
178
|
-
maintenanceRecheck = setTimeout(() => {
|
|
179
|
-
maintenanceRecheck = null;
|
|
180
|
-
if (gen === generation)
|
|
181
|
-
void bootAndArm(gen, { recheck: true }).catch(() => undefined);
|
|
182
|
-
}, sdk_core_1.MAINTENANCE_RECHECK_MS);
|
|
183
|
-
}
|
|
184
|
-
function clearMaintenanceRecheck() {
|
|
185
|
-
if (maintenanceRecheck !== null)
|
|
186
|
-
clearTimeout(maintenanceRecheck);
|
|
187
|
-
maintenanceRecheck = null;
|
|
188
|
-
}
|
|
189
|
-
function setStatus(next) {
|
|
190
|
-
if (status === next)
|
|
191
|
-
return;
|
|
192
|
-
status = next;
|
|
193
|
-
for (const listener of statusListeners) {
|
|
194
|
-
try {
|
|
195
|
-
listener(next);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
/* a listener must never break the client */
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* The `url` a report carries right now (spec 0004 §D). With an `origin` configured
|
|
204
|
-
* it reads `<origin>/<screen>`; without one — the default since ADR-0041 — the
|
|
205
|
-
* screen stands alone, so the Evidence still says *where* the reporter was rather
|
|
206
|
-
* than losing it to a setting nobody has to fill in. Ingest takes `url` as a free
|
|
207
|
-
* string and the dashboard renders it as text, so a bare screen name is honest
|
|
208
|
-
* here where a synthesised origin would be fiction.
|
|
209
|
-
*/
|
|
210
|
-
function currentUrl() {
|
|
211
|
-
if (!origin)
|
|
212
|
-
return currentScreen ? screenPath(currentScreen) : undefined;
|
|
213
|
-
return currentScreen ? `${origin}/${screenPath(currentScreen)}` : origin;
|
|
214
|
-
}
|
|
215
|
-
/** The transport deps every call shares: the injected seams plus this app's origin. */
|
|
216
|
-
function submitDeps() {
|
|
217
|
-
return { fetch: adapters.fetch, formData: adapters.formData, origin };
|
|
218
|
-
}
|
|
53
|
+
const s = (0, client_state_1.createClientState)(options, adapters);
|
|
54
|
+
const { warn, now, readEnvironment, key, apiUrl, release, buffer } = s;
|
|
55
|
+
const replay = (0, client_replay_1.createReplayArming)(s);
|
|
56
|
+
const { releaseReplayFile, releaseReplayWindow, encodeReplay } = replay;
|
|
57
|
+
const { bootAndArm, resolveStreams, armTrace, disarmCapture, setMaintenance, clearMaintenanceRecheck, } = (0, client_boot_1.createBoot)(s, replay);
|
|
58
|
+
const { signIn, signOut, armConnectLinking, redeemLaunchUrl, settleInboundWaiters } = (0, client_connect_1.createConnect)(s, bootAndArm);
|
|
219
59
|
/**
|
|
220
60
|
* Step one of `start`: settle the identity evidence this session presents — the host
|
|
221
61
|
* app's own `hostIdentity`, the SDK-managed anonymous id (persisted when storage is
|
|
@@ -225,396 +65,32 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
225
65
|
*/
|
|
226
66
|
async function resolveIdentity() {
|
|
227
67
|
const anonymousId = options.anonymousId ?? (await (0, identity_1.ensureAnonymousId)(adapters.storage));
|
|
228
|
-
reporterSessionToken = await (0, reporter_session_store_1.readReporterSession)(adapters.secureStore, key);
|
|
68
|
+
s.reporterSessionToken = await (0, reporter_session_store_1.readReporterSession)(adapters.secureStore, key);
|
|
229
69
|
return {
|
|
230
70
|
...(options.hostIdentity ? { hostIdentity: options.hostIdentity } : {}),
|
|
231
|
-
...(reporterSessionToken ? { reporterSession: reporterSessionToken } : {}),
|
|
71
|
+
...(s.reporterSessionToken ? { reporterSession: s.reporterSessionToken } : {}),
|
|
232
72
|
anonymousId,
|
|
233
73
|
};
|
|
234
74
|
}
|
|
235
|
-
/** The current Reporter identity for the composer's identity row and `identity()`. */
|
|
236
|
-
function reporterIdentity() {
|
|
237
|
-
return account
|
|
238
|
-
? {
|
|
239
|
-
status: "connected",
|
|
240
|
-
name: account.name,
|
|
241
|
-
email: account.email,
|
|
242
|
-
tier: currentTier ?? null,
|
|
243
|
-
}
|
|
244
|
-
: { status: "anonymous" };
|
|
245
|
-
}
|
|
246
|
-
/** Tell identity listeners the identity may have changed (boot / sign in / sign out). */
|
|
247
|
-
function notifyIdentity() {
|
|
248
|
-
const current = reporterIdentity();
|
|
249
|
-
for (const listener of identityListeners) {
|
|
250
|
-
try {
|
|
251
|
-
listener(current);
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
/* a listener must never break the client */
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
/** Tear down the trace hooks (best-effort), so they can be re-armed. */
|
|
259
|
-
function disarmTrace() {
|
|
260
|
-
try {
|
|
261
|
-
teardownTrace?.();
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
/* teardown is best-effort */
|
|
265
|
-
}
|
|
266
|
-
teardownTrace = null;
|
|
267
|
-
}
|
|
268
|
-
/** Tear down the trace + auto-capture hooks (best-effort), so they can be re-armed. */
|
|
269
|
-
function disarmCapture() {
|
|
270
|
-
disarmTrace();
|
|
271
|
-
disarmReplay();
|
|
272
|
-
try {
|
|
273
|
-
autoCapture?.destroy();
|
|
274
|
-
}
|
|
275
|
-
catch {
|
|
276
|
-
/* teardown is best-effort */
|
|
277
|
-
}
|
|
278
|
-
autoCapture = null;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* The effective per-stream capture flags: an init option overrides the served
|
|
282
|
-
* config, and an absent (or malformed) served value means **on** — the default-on
|
|
283
|
-
* rule (spec 0003 §C, ported). Called with no answer before boot has one, which is
|
|
284
|
-
* how the pre-boot arming lands on "both streams on".
|
|
285
|
-
*/
|
|
286
|
-
function resolveStreams(served) {
|
|
287
|
-
return {
|
|
288
|
-
console: options.capture?.console ??
|
|
289
|
-
(typeof served?.console === "boolean" ? served.console : true),
|
|
290
|
-
network: options.capture?.network ??
|
|
291
|
-
(typeof served?.network === "boolean" ? served.network : true),
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Whether this app asked for mobile replay. Unlike every other stream, replay is
|
|
296
|
-
* **off until asked for** (ADR-0044): it records the device screen unmasked, so
|
|
297
|
-
* shipping an SDK update must never start it. Either spelling opts in —
|
|
298
|
-
* `capture: { replay: true }`, or simply passing a `replay` tuning object — and an
|
|
299
|
-
* explicit `capture: { replay: false }` always wins.
|
|
300
|
-
*/
|
|
301
|
-
function replayRequested() {
|
|
302
|
-
if (typeof options.capture?.replay === "boolean")
|
|
303
|
-
return options.capture.replay;
|
|
304
|
-
return options.replay !== undefined;
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Whether the server turned mobile replay off: the Project's replay toggle (the master kill
|
|
308
|
-
* switch, #138), or — on a server that sends it — `mobileReplay`, that toggle under the
|
|
309
|
-
* platform's `mobile_replay` feature flag for the workspace (#647). An older server sends only
|
|
310
|
-
* `replay`, which is then read as before.
|
|
311
|
-
*/
|
|
312
|
-
function servedOff(served) {
|
|
313
|
-
if (typeof served?.replay === "boolean" && !served.replay)
|
|
314
|
-
return "project";
|
|
315
|
-
if (typeof served?.mobileReplay === "boolean" && !served.mobileReplay)
|
|
316
|
-
return "platform";
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Whether replay records: this app asked for it, the server has not turned it off, and the
|
|
321
|
-
* platform can actually capture and encode frames.
|
|
322
|
-
*/
|
|
323
|
-
function replayEnabled(served) {
|
|
324
|
-
if (!replayRequested())
|
|
325
|
-
return false;
|
|
326
|
-
if (servedOff(served) !== null)
|
|
327
|
-
return false;
|
|
328
|
-
return Boolean(adapters.captureReplayFrame && adapters.encodeReplaySegment);
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Start (or stop) the frame recorder for the Project's served config. Re-armed on
|
|
332
|
-
* every boot, so a Project that turns replay off stops recording on the next boot
|
|
333
|
-
* rather than at the next launch — and the frames already buffered are deleted with
|
|
334
|
-
* the recorder, so nothing captured under the old answer can ship.
|
|
335
|
-
*/
|
|
336
|
-
function armReplay(served) {
|
|
337
|
-
disarmReplay();
|
|
338
|
-
if (!replayEnabled(served)) {
|
|
339
|
-
const off = servedOff(served);
|
|
340
|
-
if (replayRequested() && off === "project") {
|
|
341
|
-
warn("Fixback: replay is enabled in init but turned off for this Project — " +
|
|
342
|
-
"no replay will be recorded until it is enabled in the dashboard.");
|
|
343
|
-
}
|
|
344
|
-
else if (replayRequested() && off === "platform") {
|
|
345
|
-
warn("Fixback: replay is enabled in init but mobile replay isn't available for this " +
|
|
346
|
-
"workspace yet — no replay will be recorded.");
|
|
347
|
-
}
|
|
348
|
-
else if (replayRequested() && !adapters.encodeReplaySegment) {
|
|
349
|
-
warn("Fixback: replay is enabled in init but no native encoder is linked — " +
|
|
350
|
-
"mobile replay needs a development build (`npx expo prebuild`), not Expo Go.");
|
|
351
|
-
}
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
replayRecorder = (0, replay_1.startMobileReplayRecorder)(options.replay ?? {}, {
|
|
355
|
-
captureFrame: (quality) => adapters.captureReplayFrame(quality),
|
|
356
|
-
releaseFrame: (uri) => releaseReplayFile(uri),
|
|
357
|
-
isActive: adapters.isAppActive,
|
|
358
|
-
now,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
/** Stop the recorder and drop every frame it owns (best-effort). */
|
|
362
|
-
function disarmReplay() {
|
|
363
|
-
try {
|
|
364
|
-
replayRecorder?.stop();
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
/* teardown is best-effort */
|
|
368
|
-
}
|
|
369
|
-
replayRecorder = null;
|
|
370
|
-
pendingReplayWindow = null;
|
|
371
|
-
}
|
|
372
|
-
/** Delete a replay frame or encoded window file (best-effort, repeat-safe). */
|
|
373
|
-
function releaseReplayFile(uri) {
|
|
374
|
-
try {
|
|
375
|
-
adapters.releaseReplayFrame?.(uri);
|
|
376
|
-
}
|
|
377
|
-
catch {
|
|
378
|
-
/* release is best-effort */
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
/** Give a frozen window back to the recorder, so its frames can be reclaimed. */
|
|
382
|
-
function releaseReplayWindow(window) {
|
|
383
|
-
if (!window)
|
|
384
|
-
return;
|
|
385
|
-
try {
|
|
386
|
-
replayRecorder?.release(window);
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
/* release is best-effort */
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Freeze the window as it stands **now** and encode it — the auto-capture path's
|
|
394
|
-
* whole replay step (ADR-0044): an uncaught error has no composer to open, so the
|
|
395
|
-
* throw itself is the moment the window is taken. The window is given back as soon
|
|
396
|
-
* as the encode is done, since nothing holds a draft for an auto report.
|
|
397
|
-
*/
|
|
398
|
-
async function freezeAndEncodeReplay() {
|
|
399
|
-
const window = replayRecorder?.snapshot() ?? null;
|
|
400
|
-
if (!window)
|
|
401
|
-
return null;
|
|
402
|
-
try {
|
|
403
|
-
return await encodeReplay(window);
|
|
404
|
-
}
|
|
405
|
-
finally {
|
|
406
|
-
releaseReplayWindow(window);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Encode a frozen window for transport (ADR-0044). `null` whenever the window
|
|
411
|
-
* cannot become a video — no recorder, nothing watchable buffered, or an encoder
|
|
412
|
-
* that declined — so the report ships without replay rather than not at all.
|
|
413
|
-
*/
|
|
414
|
-
async function encodeReplay(window) {
|
|
415
|
-
if (!window || !adapters.encodeReplaySegment)
|
|
416
|
-
return null;
|
|
417
|
-
return (0, replay_encode_1.encodeReplayWindow)(window, {
|
|
418
|
-
encodeSegment: adapters.encodeReplaySegment,
|
|
419
|
-
windowSize: () => {
|
|
420
|
-
const env = readEnvironment();
|
|
421
|
-
return {
|
|
422
|
-
width: env.windowWidth ?? 0,
|
|
423
|
-
height: env.windowHeight ?? 0,
|
|
424
|
-
};
|
|
425
|
-
},
|
|
426
|
-
maxEdge: (0, replay_1.resolvedMaxEdge)(options.replay),
|
|
427
|
-
now,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Install the trace instrumentation for the given per-stream flags, replacing
|
|
432
|
-
* whatever is installed — the teardown runs first, so a re-arm can never double-wrap
|
|
433
|
-
* `console` or `XMLHttpRequest`.
|
|
434
|
-
*/
|
|
435
|
-
function armTrace(streams) {
|
|
436
|
-
disarmTrace();
|
|
437
|
-
teardownTrace = (0, breadcrumbs_1.instrumentBreadcrumbs)(buffer, {
|
|
438
|
-
consoleObj: adapters.consoleObj,
|
|
439
|
-
xhr: adapters.xhr,
|
|
440
|
-
ignoreUrl: (url) => (0, sdk_core_1.isFixbackApiRequest)(apiUrl, url),
|
|
441
|
-
now,
|
|
442
|
-
captureConsole: streams.console,
|
|
443
|
-
captureNetwork: streams.network,
|
|
444
|
-
propagateTo: propagation.origins,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Boot with the current identity and reconcile capture + the identity row. Shared by
|
|
449
|
-
* `start`, `signIn`, and `signOut`: it swaps in the refreshed Reporter session token
|
|
450
|
-
* (30-day sliding), records the Account / tier / connect URL the boot answer carries,
|
|
451
|
-
* and arms capture only while a submission would be accepted — so a gated Project's
|
|
452
|
-
* shake gesture arms exactly when the Account becomes eligible, and drops when it signs
|
|
453
|
-
* out. `gen` is the caller's lifecycle generation; every await re-checks it so a
|
|
454
|
-
* destroyed (or restarted) client abandons this in-flight boot.
|
|
455
|
-
*/
|
|
456
|
-
async function bootAndArm(gen, { recheck = false } = {}) {
|
|
457
|
-
// Boots are last-one-wins. Two can be in flight at once — a deep link redeemed
|
|
458
|
-
// while `start`'s boot is still out, a `signOut` racing a Connect — and they carry
|
|
459
|
-
// *different* identities, so an older answer landing last would undo a newer one:
|
|
460
|
-
// the shake gesture would stay withheld from a Reporter the server had just
|
|
461
|
-
// accepted, with the session sitting unused in SecureStore until the next launch.
|
|
462
|
-
const seq = ++bootSequence;
|
|
463
|
-
let failure = null;
|
|
464
|
-
const answer = await (0, sdk_core_1.requestBoot)(apiUrl, { key, ...(0, sdk_core_1.compactIdentity)(identity) }, {
|
|
465
|
-
fetch: adapters.fetch,
|
|
466
|
-
origin,
|
|
467
|
-
onFailure: (reason) => {
|
|
468
|
-
failure = reason;
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
if (gen !== generation || seq !== bootSequence)
|
|
472
|
-
return;
|
|
473
|
-
// Mid-maintenance, an unanswered boot is Fixback restarting, not a reason to disarm:
|
|
474
|
-
// keep the notice up and ask again (the maintenance-mode ADR).
|
|
475
|
-
if (!answer && maintenance !== null && status === "ready") {
|
|
476
|
-
scheduleMaintenanceRecheck();
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
boot = answer;
|
|
480
|
-
// Swap in the refreshed session token (30-day sliding) when boot returned one.
|
|
481
|
-
if (answer?.reporterSession) {
|
|
482
|
-
reporterSessionToken = answer.reporterSession;
|
|
483
|
-
identity = { ...identity, reporterSession: reporterSessionToken };
|
|
484
|
-
await (0, reporter_session_store_1.writeReporterSession)(adapters.secureStore, key, reporterSessionToken);
|
|
485
|
-
if (gen !== generation || seq !== bootSequence)
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
currentTier = answer?.tier ?? null;
|
|
489
|
-
account = answer?.account ?? null;
|
|
490
|
-
connectUrl = answer?.connectUrl;
|
|
491
|
-
notifyIdentity();
|
|
492
|
-
// Maintenance mode's notice (the maintenance-mode ADR) — sent only to a caller who could
|
|
493
|
-
// otherwise report, so it arms the composer as a yes does: it opens onto the notice.
|
|
494
|
-
const notice = (0, sdk_core_1.maintenanceOf)(answer);
|
|
495
|
-
// A maintenance recheck on a client already armed changes only the notice: re-arming would
|
|
496
|
-
// restart the replay recorder every minute and drop the lead-up the next report should carry.
|
|
497
|
-
if (recheck && status === "ready" && answer && (answer.canSubmit || notice)) {
|
|
498
|
-
setMaintenance(notice);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
if (answer && (answer.canSubmit || notice)) {
|
|
502
|
-
// Re-arm from a clean slate (the shared trace buffer persists across a re-arm, so
|
|
503
|
-
// no lead-up is lost) — a Connect that just made the Account eligible arms here.
|
|
504
|
-
disarmCapture();
|
|
505
|
-
armCapture(answer);
|
|
506
|
-
lastDormancyWarning = null;
|
|
507
|
-
setMaintenance(notice);
|
|
508
|
-
setStatus("ready");
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
setMaintenance(null);
|
|
512
|
-
// Say why, once per dormant boot, in a development build (`warn` is silent in
|
|
513
|
-
// production). A dormant SDK is otherwise indistinguishable on the device from
|
|
514
|
-
// one that was never installed — and the reason is right here in the answer the
|
|
515
|
-
// client already holds, or in the failure that stopped one arriving.
|
|
516
|
-
warnDormant(answer, failure);
|
|
517
|
-
// Not eligible (a gated Project the Account cannot yet report on, or a signed-out
|
|
518
|
-
// visitor): nothing armed, but `connectUrl` is retained so `signIn` still works.
|
|
519
|
-
// What the pre-boot arming optimistically recorded is dropped with it — this
|
|
520
|
-
// Account cannot submit, so holding its console and network lead-up in memory
|
|
521
|
-
// serves nothing. A later `signIn` re-arms and starts a fresh lead-up.
|
|
522
|
-
disarmCapture();
|
|
523
|
-
buffer.clear();
|
|
524
|
-
setStatus("disabled");
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* The last dormancy line printed, so a re-boot that changes nothing (a `signOut`
|
|
529
|
-
* back to the same refusal, an app resumed) does not repeat itself. Reset whenever
|
|
530
|
-
* the client becomes able to submit, so a *later* relapse is reported again.
|
|
531
|
-
*/
|
|
532
|
-
let lastDormancyWarning = null;
|
|
533
|
-
/** Explain a dormant boot to the developer, at most once per distinct reason. */
|
|
534
|
-
function warnDormant(answer, failure) {
|
|
535
|
-
const message = (0, sdk_core_1.explainDormancy)({
|
|
536
|
-
answer,
|
|
537
|
-
failure,
|
|
538
|
-
apiUrl,
|
|
539
|
-
origin: origin || undefined,
|
|
540
|
-
// Connect needs a door this app can actually open — an auth session it can
|
|
541
|
-
// return through, or an in-app browser plus the seam the redirect comes back
|
|
542
|
-
// on. With neither, naming `signIn()` would send the developer down a dead end.
|
|
543
|
-
signInCall: canSignIn() ? "Fixback.signIn()" : undefined,
|
|
544
|
-
});
|
|
545
|
-
if (!message || message === lastDormancyWarning)
|
|
546
|
-
return;
|
|
547
|
-
lastDormancyWarning = message;
|
|
548
|
-
warn(message);
|
|
549
|
-
}
|
|
550
|
-
/**
|
|
551
|
-
* Step two of `start`: with a boot answer that says a submission would be
|
|
552
|
-
* accepted, reconcile the trace instrumentation against the Project's served
|
|
553
|
-
* capture config and install (unless turned off) automatic error capture.
|
|
554
|
-
* Reaching here means the Gate is already satisfied, so an auto-error inherits
|
|
555
|
-
* the very tier boot derived.
|
|
556
|
-
*
|
|
557
|
-
* The trace was already armed optimistically at `start()`, so this is a
|
|
558
|
-
* **reconciliation**, not the first arming: a stream the Project turns off is both
|
|
559
|
-
* uninstrumented and **purged**, so what the optimistic window recorded for it can
|
|
560
|
-
* never ship.
|
|
561
|
-
*/
|
|
562
|
-
function armCapture(answer) {
|
|
563
|
-
const streams = resolveStreams(answer.capture);
|
|
564
|
-
armTrace(streams);
|
|
565
|
-
if (!streams.console)
|
|
566
|
-
buffer.clearStream("console");
|
|
567
|
-
if (!streams.network)
|
|
568
|
-
buffer.clearStream("network");
|
|
569
|
-
// Replay arms **here and only here** — never optimistically before boot, the way
|
|
570
|
-
// the trace does. A trace that turns out to be unwanted is purged; frames already
|
|
571
|
-
// written to the cache are a recording that should not have started (ADR-0044).
|
|
572
|
-
armReplay(answer.capture);
|
|
573
|
-
if (options.autoCapture === false)
|
|
574
|
-
return;
|
|
575
|
-
autoCapture = (0, error_capture_1.installErrorCapture)({
|
|
576
|
-
apiUrl,
|
|
577
|
-
key,
|
|
578
|
-
origin,
|
|
579
|
-
identity,
|
|
580
|
-
sdkVersion: version_1.EXPO_SDK_VERSION,
|
|
581
|
-
release,
|
|
582
|
-
buffer,
|
|
583
|
-
beforeSend: options.beforeSend,
|
|
584
|
-
scrub: options.scrub,
|
|
585
|
-
environment: readEnvironment,
|
|
586
|
-
currentUrl,
|
|
587
|
-
// The replay window as it stood at the throw (ADR-0044): frozen and encoded at
|
|
588
|
-
// filing time, on the rich first report only — a flush is a count update, and
|
|
589
|
-
// re-encoding the same window for it would be pure cost.
|
|
590
|
-
captureReplay: () => freezeAndEncodeReplay(),
|
|
591
|
-
errorUtils: adapters.errorUtils,
|
|
592
|
-
appState: adapters.appState,
|
|
593
|
-
deps: {
|
|
594
|
-
submitReport: (url, input) => (0, submit_1.submitReport)(url, input, submitDeps()),
|
|
595
|
-
},
|
|
596
|
-
now,
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
75
|
async function start() {
|
|
600
|
-
if (running)
|
|
76
|
+
if (s.running)
|
|
601
77
|
return;
|
|
602
|
-
running = true;
|
|
603
|
-
const gen = ++generation;
|
|
604
|
-
setStatus("starting");
|
|
78
|
+
s.running = true;
|
|
79
|
+
const gen = ++s.generation;
|
|
80
|
+
(0, client_state_1.setStatus)(s, "starting");
|
|
605
81
|
try {
|
|
606
82
|
// The master gate (`enabled: false`) keeps the SDK completely dormant: no
|
|
607
83
|
// identity, no boot call, nothing instrumented.
|
|
608
84
|
if (options.enabled === false) {
|
|
609
|
-
setStatus("disabled");
|
|
85
|
+
(0, client_state_1.setStatus)(s, "disabled");
|
|
610
86
|
return;
|
|
611
87
|
}
|
|
612
88
|
// Only the key is required. An `origin` used to be mandatory here because
|
|
613
89
|
// ingest refused a submission without one; it no longer does (ADR-0041), so an
|
|
614
90
|
// app that configures none boots and reports normally.
|
|
615
91
|
if (!key) {
|
|
616
|
-
warn(
|
|
617
|
-
setStatus("disabled");
|
|
92
|
+
warn(`${sdk_core_1.WARN_PREFIX} init needs a publishable \`key\`; staying dormant.`);
|
|
93
|
+
(0, client_state_1.setStatus)(s, "disabled");
|
|
618
94
|
return;
|
|
619
95
|
}
|
|
620
96
|
// Arm the trace **before** boot. Boot is a network round trip, and reading the
|
|
@@ -627,8 +103,8 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
627
103
|
// turns out to be off. Nothing can leave the process in between: a submission
|
|
628
104
|
// needs `ready`, which only a `canSubmit` boot sets.
|
|
629
105
|
armTrace(resolveStreams(undefined));
|
|
630
|
-
identity = await resolveIdentity();
|
|
631
|
-
if (gen !== generation)
|
|
106
|
+
s.identity = await resolveIdentity();
|
|
107
|
+
if (gen !== s.generation)
|
|
632
108
|
return; // destroyed (or restarted) while resolving
|
|
633
109
|
// Listen for Connect deep links before booting, so one delivered while the boot
|
|
634
110
|
// round trip is in flight is not missed (ADR-0046).
|
|
@@ -637,7 +113,7 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
637
113
|
// invite the person claimed a moment ago is what opened the app, and the boot
|
|
638
114
|
// below should already carry the Reporter session it established.
|
|
639
115
|
await redeemLaunchUrl(gen);
|
|
640
|
-
if (gen !== generation)
|
|
116
|
+
if (gen !== s.generation)
|
|
641
117
|
return; // destroyed (or restarted) while redeeming
|
|
642
118
|
// Boot, then arm auto-capture (and the shake gesture, via `ready`) only if a
|
|
643
119
|
// submission would be accepted; sets `disabled` otherwise, keeping `connectUrl`
|
|
@@ -651,290 +127,22 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
651
127
|
warn(`Fixback is dormant: start() failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
652
128
|
// The optimistic pre-boot trace goes dormant with it: no submission can follow
|
|
653
129
|
// a start that failed, so nothing should stay instrumented or held in memory.
|
|
654
|
-
if (gen === generation) {
|
|
130
|
+
if (gen === s.generation) {
|
|
655
131
|
disarmCapture();
|
|
656
132
|
buffer.clear();
|
|
657
|
-
setStatus("disabled");
|
|
133
|
+
(0, client_state_1.setStatus)(s, "disabled");
|
|
658
134
|
}
|
|
659
135
|
}
|
|
660
136
|
}
|
|
661
|
-
/**
|
|
662
|
-
* Start a **Connect** (ADR-0066): open the platform's connect page, exchange the
|
|
663
|
-
* one-time code it hands back for a Reporter session, store it in SecureStore, and
|
|
664
|
-
* re-boot as the Account — so a gated Project's shake gesture arms once the Account
|
|
665
|
-
* is eligible. Exposed so a host app can put it on its own settings screen (the way
|
|
666
|
-
* a gated Project, which shows nothing to a signed-out visitor, is entered), and
|
|
667
|
-
* behind the composer's identity row. Never throws: any failure leaves the current
|
|
668
|
-
* identity untouched.
|
|
669
|
-
*
|
|
670
|
-
* There are two doors, and which one opens depends only on what this app can offer
|
|
671
|
-
* (ADR-0052):
|
|
672
|
-
*
|
|
673
|
-
* - **The auth session**, when `init` declared a `returnUrl` and an opener is
|
|
674
|
-
* installed. The page returns *in place*, so the person never leaves the app.
|
|
675
|
-
* - **The in-app browser**, otherwise. The page is opened with no return URL and
|
|
676
|
-
* resolves where to land from the app links the *Project* lists, then redirects
|
|
677
|
-
* to one — which re-opens this app, where the inbound listener redeems the code
|
|
678
|
-
* (the journey a claimed Invite already travels). This is what keeps `signIn()`
|
|
679
|
-
* from being inert on the default install, which declares no `returnUrl` at all.
|
|
680
|
-
*/
|
|
681
|
-
async function signIn() {
|
|
682
|
-
const gen = generation;
|
|
683
|
-
if (!connectUrl)
|
|
684
|
-
return reporterIdentity(); // not booted, or an older server
|
|
685
|
-
try {
|
|
686
|
-
if (options.returnUrl && adapters.openAuthSession) {
|
|
687
|
-
const outcome = await (0, connect_1.startExpoConnect)(adapters.openAuthSession, {
|
|
688
|
-
connectUrl,
|
|
689
|
-
key,
|
|
690
|
-
returnUrl: options.returnUrl,
|
|
691
|
-
});
|
|
692
|
-
if (gen !== generation)
|
|
693
|
-
return reporterIdentity();
|
|
694
|
-
if (outcome.kind === "code")
|
|
695
|
-
await redeemConnectCode(outcome.code, gen);
|
|
696
|
-
}
|
|
697
|
-
else if (canConnectThroughBrowser()) {
|
|
698
|
-
await connectThroughBrowser(gen);
|
|
699
|
-
}
|
|
700
|
-
else {
|
|
701
|
-
warn(explainSignInDoor());
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
catch {
|
|
705
|
-
// A Connect problem must never disturb the host app.
|
|
706
|
-
}
|
|
707
|
-
return reporterIdentity();
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* Whether the browser door is available: something to open the page with, and the
|
|
711
|
-
* `linking` seam the code comes back on. Without the seam the page would land the
|
|
712
|
-
* person back in the app with a code nothing reads, which is worse than not opening
|
|
713
|
-
* it — so both are required, not either.
|
|
714
|
-
*/
|
|
715
|
-
function canConnectThroughBrowser() {
|
|
716
|
-
return Boolean(adapters.openBrowser && adapters.linking);
|
|
717
|
-
}
|
|
718
|
-
/** Whether `signIn()` can open a door at all — what the dormancy hint is gated on. */
|
|
719
|
-
function canSignIn() {
|
|
720
|
-
return Boolean((options.returnUrl && adapters.openAuthSession) || canConnectThroughBrowser());
|
|
721
|
-
}
|
|
722
|
-
/** Why `signIn()` could not open a door, named by what this app is actually missing. */
|
|
723
|
-
function explainSignInDoor() {
|
|
724
|
-
if (!adapters.openBrowser && !adapters.openAuthSession) {
|
|
725
|
-
return "Fixback: signIn needs a browser to open the connect page — install expo-web-browser.";
|
|
726
|
-
}
|
|
727
|
-
if (!adapters.linking) {
|
|
728
|
-
return "Fixback: signIn needs the Linking seam to receive the connect page's redirect — or declare a `returnUrl` in init to return through an auth session instead.";
|
|
729
|
-
}
|
|
730
|
-
return "Fixback: signIn needs either an auth-session opener plus a `returnUrl` in init, or an in-app browser (install expo-web-browser).";
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* The browser door (ADR-0052): open the connect page with no return URL and wait for
|
|
734
|
-
* the app link it redirects to, which the OS delivers back here as a deep link.
|
|
735
|
-
*
|
|
736
|
-
* The wait matters because the two halves are separated by the operating system: the
|
|
737
|
-
* page redirects, the app is foregrounded, and {@link handleInboundUrl} redeems —
|
|
738
|
-
* all after `openBrowser` has resolved (on Android it resolves as soon as the tab is
|
|
739
|
-
* shown). Without it, an awaited `signIn()` would report the anonymous identity it
|
|
740
|
-
* started from while the Connect it started was still completing. It is bounded, so
|
|
741
|
-
* a person who wandered off never leaves a promise pending for the life of the app.
|
|
742
|
-
*/
|
|
743
|
-
async function connectThroughBrowser(gen) {
|
|
744
|
-
const landed = awaitInboundConnect();
|
|
745
|
-
try {
|
|
746
|
-
await adapters.openBrowser((0, connect_1.buildAppConnectUrl)(connectUrl, key));
|
|
747
|
-
}
|
|
748
|
-
catch {
|
|
749
|
-
settleInboundWaiters(false);
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
if (gen !== generation)
|
|
753
|
-
return settleInboundWaiters(false);
|
|
754
|
-
// The redirect re-opens this app; dismiss the page it came from so the person is
|
|
755
|
-
// returned to where they tapped Sign in rather than to a finished connect page.
|
|
756
|
-
if (await landed)
|
|
757
|
-
dismissBrowser();
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* A promise for the next inbound Connect — `true` when one is redeemed, `false` when
|
|
761
|
-
* the wait expires or the client is destroyed. The timer is cleared on either, so a
|
|
762
|
-
* completed sign-in leaves nothing running.
|
|
763
|
-
*/
|
|
764
|
-
function awaitInboundConnect() {
|
|
765
|
-
return new Promise((resolve) => {
|
|
766
|
-
const timer = setTimeout(() => settleInboundWaiters(false), CONNECT_BROWSER_WAIT_MS);
|
|
767
|
-
inboundWaiters.push((landed) => {
|
|
768
|
-
clearTimeout(timer);
|
|
769
|
-
resolve(landed);
|
|
770
|
-
});
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
/** Resolve everyone waiting on an inbound Connect, and stop waiting. */
|
|
774
|
-
function settleInboundWaiters(landed) {
|
|
775
|
-
const waiting = inboundWaiters;
|
|
776
|
-
inboundWaiters = [];
|
|
777
|
-
for (const resolve of waiting) {
|
|
778
|
-
try {
|
|
779
|
-
resolve(landed);
|
|
780
|
-
}
|
|
781
|
-
catch {
|
|
782
|
-
/* a waiter must never break the client */
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
/** Close the in-app browser this app opened — best-effort, like every seam here. */
|
|
787
|
-
function dismissBrowser() {
|
|
788
|
-
try {
|
|
789
|
-
adapters.dismissBrowser?.();
|
|
790
|
-
}
|
|
791
|
-
catch {
|
|
792
|
-
/* dismissing is a courtesy, never a failure */
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
/**
|
|
796
|
-
* Exchange a one-time Connect code for a Reporter session and persist it, leaving the
|
|
797
|
-
* client identified as the Account. The tail every half of Connect on mobile shares:
|
|
798
|
-
* {@link signIn}, where the app opened the connect page itself, and
|
|
799
|
-
* {@link handleInboundUrl}, where the code arrived on a deep link from an invite the
|
|
800
|
-
* person claimed elsewhere (ADR-0046). `gen` is the caller's lifecycle generation,
|
|
801
|
-
* re-checked after every await so a destroyed client abandons the redemption.
|
|
802
|
-
*
|
|
803
|
-
* Booting on the new identity is the **caller's** step, because the two callers
|
|
804
|
-
* differ: a redemption mid-run must re-boot to arm, while the one on the launch URL
|
|
805
|
-
* is followed by `start`'s own boot and would only duplicate it.
|
|
806
|
-
*/
|
|
807
|
-
async function exchangeAndStore(code, gen) {
|
|
808
|
-
const result = await (0, sdk_core_1.exchangeConnectCode)(apiUrl, {
|
|
809
|
-
key,
|
|
810
|
-
code,
|
|
811
|
-
anonymousId: identity.anonymousId,
|
|
812
|
-
platform: "expo",
|
|
813
|
-
}, { fetch: adapters.fetch });
|
|
814
|
-
if (gen !== generation || !result)
|
|
815
|
-
return false;
|
|
816
|
-
reporterSessionToken = result.reporterSession;
|
|
817
|
-
identity = { ...identity, reporterSession: reporterSessionToken };
|
|
818
|
-
await (0, reporter_session_store_1.writeReporterSession)(adapters.secureStore, key, reporterSessionToken);
|
|
819
|
-
return gen === generation;
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* {@link exchangeAndStore} plus the re-boot that arms the client as the Account, and
|
|
823
|
-
* whether the Connect landed. A refused exchange changes no identity, so it is not
|
|
824
|
-
* re-booted on: the answer would be the one already in hand.
|
|
825
|
-
*/
|
|
826
|
-
async function redeemConnectCode(code, gen) {
|
|
827
|
-
if (!(await exchangeAndStore(code, gen)))
|
|
828
|
-
return false;
|
|
829
|
-
await bootAndArm(gen);
|
|
830
|
-
return true;
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Finish a Connect that started outside the app: read the one-time code off an
|
|
834
|
-
* inbound deep link and redeem it. Ignores a code it has already seen — iOS and
|
|
835
|
-
* Android both replay the launch URL in ordinary situations (a cold start whose
|
|
836
|
-
* `getInitialURL` is then re-delivered as a `url` event), and a code is single-use,
|
|
837
|
-
* so a second exchange would fail and report a Connect that in fact succeeded as one
|
|
838
|
-
* that did not. A declared `returnUrl` additionally narrows this to links on the
|
|
839
|
-
* app's own return origin; an app that declared none still redeems, because the
|
|
840
|
-
* claim page mints its code against the **Project's** app link, not against `init`.
|
|
841
|
-
*/
|
|
842
|
-
async function handleInboundUrl(url, gen, { reboot = true } = {}) {
|
|
843
|
-
try {
|
|
844
|
-
const code = (0, connect_1.parseInboundConnectCode)(url, options.returnUrl);
|
|
845
|
-
if (!code || code === lastInboundCode)
|
|
846
|
-
return;
|
|
847
|
-
lastInboundCode = code;
|
|
848
|
-
const landed = reboot
|
|
849
|
-
? await redeemConnectCode(code, gen)
|
|
850
|
-
: await exchangeAndStore(code, gen);
|
|
851
|
-
// A `signIn()` that went through the browser door is waiting on exactly this —
|
|
852
|
-
// the code it sent the person out for, arriving back on the app's own link.
|
|
853
|
-
if (landed)
|
|
854
|
-
settleInboundWaiters(true);
|
|
855
|
-
}
|
|
856
|
-
catch {
|
|
857
|
-
// A Connect problem must never disturb the host app.
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
/**
|
|
861
|
-
* Listen for Connect deep links that arrive **while the app runs** — the person was
|
|
862
|
-
* already in the app (or it was backgrounded) when they tapped the claim page's
|
|
863
|
-
* button. Armed on the `linking` seam alone: a Project's app link is allowlisted on
|
|
864
|
-
* the Project, so an app that never declared a `returnUrl` in `init` is still where
|
|
865
|
-
* a claim lands (ADR-0046). Silent on an app that provides no seam, which can never
|
|
866
|
-
* be a Connect target.
|
|
867
|
-
*/
|
|
868
|
-
function armConnectLinking(gen) {
|
|
869
|
-
const linking = adapters.linking;
|
|
870
|
-
if (!linking)
|
|
871
|
-
return;
|
|
872
|
-
try {
|
|
873
|
-
const subscription = linking.addEventListener("url", (event) => {
|
|
874
|
-
void handleInboundUrl(event?.url ?? "", gen);
|
|
875
|
-
});
|
|
876
|
-
teardownLinking = () => subscription.remove();
|
|
877
|
-
}
|
|
878
|
-
catch {
|
|
879
|
-
// A Linking seam that refuses to subscribe is not a reason to fail a start.
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
/**
|
|
883
|
-
* Redeem the code on the URL the app was **launched** with, before the first boot —
|
|
884
|
-
* the invite journey's cold start (the person tapped the claim page's redirect and
|
|
885
|
-
* the app opened). Awaited rather than left in flight, so the one boot that follows
|
|
886
|
-
* carries the Reporter session: two concurrent boots race, and an anonymous answer
|
|
887
|
-
* landing last leaves a Connect that succeeded looking like one that never happened.
|
|
888
|
-
* The browser SDK exchanges a URL code before booting for the same reason.
|
|
889
|
-
*
|
|
890
|
-
* Best-effort throughout: a seam that throws, a launch URL that carries nothing, or
|
|
891
|
-
* an exchange the server refuses all fall through to an ordinary anonymous boot.
|
|
892
|
-
*/
|
|
893
|
-
async function redeemLaunchUrl(gen) {
|
|
894
|
-
const linking = adapters.linking;
|
|
895
|
-
if (!linking)
|
|
896
|
-
return;
|
|
897
|
-
try {
|
|
898
|
-
const initial = await linking.getInitialURL();
|
|
899
|
-
if (gen !== generation || !initial)
|
|
900
|
-
return;
|
|
901
|
-
await handleInboundUrl(initial, gen, { reboot: false });
|
|
902
|
-
}
|
|
903
|
-
catch {
|
|
904
|
-
// Never throw into the host app.
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
/**
|
|
908
|
-
* Revoke the Reporter session, clear it from SecureStore, and re-boot as anonymous —
|
|
909
|
-
* so a gated Project's launcher (shake gesture) drops at once. Never throws.
|
|
910
|
-
*/
|
|
911
|
-
async function signOut() {
|
|
912
|
-
const gen = generation;
|
|
913
|
-
try {
|
|
914
|
-
if (reporterSessionToken) {
|
|
915
|
-
await (0, sdk_core_1.revokeReporterSession)(apiUrl, reporterSessionToken, {
|
|
916
|
-
fetch: adapters.fetch,
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
await (0, reporter_session_store_1.clearReporterSession)(adapters.secureStore, key);
|
|
920
|
-
reporterSessionToken = null;
|
|
921
|
-
identity = { ...identity, reporterSession: undefined };
|
|
922
|
-
if (gen === generation)
|
|
923
|
-
await bootAndArm(gen);
|
|
924
|
-
}
|
|
925
|
-
catch {
|
|
926
|
-
// A Fixback problem must never disturb the host app.
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
137
|
async function present() {
|
|
930
|
-
if (status !== "ready")
|
|
138
|
+
if (s.status !== "ready")
|
|
931
139
|
return;
|
|
932
140
|
// Freeze the replay window as the composer opens (ADR-0055's posture, ported):
|
|
933
141
|
// what the Reporter is reporting is what just happened, not what happens while
|
|
934
142
|
// they type. A window left over from a composer they dismissed is given back
|
|
935
143
|
// first, so its frames are never retained twice.
|
|
936
|
-
releaseReplayWindow(pendingReplayWindow);
|
|
937
|
-
pendingReplayWindow = replayRecorder?.snapshot() ?? null;
|
|
144
|
+
releaseReplayWindow(s.pendingReplayWindow);
|
|
145
|
+
s.pendingReplayWindow = s.replayRecorder?.snapshot() ?? null;
|
|
938
146
|
let screenshot = null;
|
|
939
147
|
if (options.screenshots !== false && adapters.captureScreenshot) {
|
|
940
148
|
try {
|
|
@@ -945,7 +153,7 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
945
153
|
}
|
|
946
154
|
}
|
|
947
155
|
const context = { screenshot };
|
|
948
|
-
for (const listener of presentListeners) {
|
|
156
|
+
for (const listener of s.presentListeners) {
|
|
949
157
|
try {
|
|
950
158
|
listener(context);
|
|
951
159
|
}
|
|
@@ -961,7 +169,7 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
961
169
|
// (ADR-0023).
|
|
962
170
|
(0, report_1.assembleContent)({
|
|
963
171
|
comment: draft.comment,
|
|
964
|
-
url: currentUrl(),
|
|
172
|
+
url: (0, client_state_1.currentUrl)(s),
|
|
965
173
|
environment: (0, report_1.collectEnvironment)(readEnvironment(), version_1.EXPO_SDK_VERSION, release),
|
|
966
174
|
trace: buffer.snapshot(),
|
|
967
175
|
}), { hook: options.beforeSend, scrub: options.scrub });
|
|
@@ -971,8 +179,8 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
971
179
|
// Encode the window frozen when the composer opened (ADR-0044). This runs after
|
|
972
180
|
// the Reporter pressed send, inside a flow they initiated — the one place the
|
|
973
181
|
// cost of an encode is theirs to spend rather than the app's.
|
|
974
|
-
const replay = await encodeReplay(pendingReplayWindow);
|
|
975
|
-
const result = await (0, submit_1.submitReport)(apiUrl, { key, identity, content, screenshot: draft.screenshot ?? null, replay }, submitDeps());
|
|
182
|
+
const replay = await encodeReplay(s.pendingReplayWindow);
|
|
183
|
+
const result = await (0, submit_1.submitReport)(apiUrl, { key, identity: s.identity, content, screenshot: draft.screenshot ?? null, replay }, (0, client_state_1.submitDeps)(s));
|
|
976
184
|
// Held for maintenance (an app that booted before it began): show the notice and
|
|
977
185
|
// ask boot when it ends — the composer keeps the draft for the Reporter to send then.
|
|
978
186
|
if (!result.ok && result.reason === "maintenance")
|
|
@@ -986,8 +194,8 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
986
194
|
if (replay)
|
|
987
195
|
releaseReplayFile(replay.file.uri);
|
|
988
196
|
if (result.ok) {
|
|
989
|
-
releaseReplayWindow(pendingReplayWindow);
|
|
990
|
-
pendingReplayWindow = null;
|
|
197
|
+
releaseReplayWindow(s.pendingReplayWindow);
|
|
198
|
+
s.pendingReplayWindow = null;
|
|
991
199
|
}
|
|
992
200
|
return result;
|
|
993
201
|
}
|
|
@@ -1017,17 +225,17 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
1017
225
|
* session and the app's cache keeps a recording nobody reported.
|
|
1018
226
|
*/
|
|
1019
227
|
function discardReplay() {
|
|
1020
|
-
releaseReplayWindow(pendingReplayWindow);
|
|
1021
|
-
pendingReplayWindow = null;
|
|
228
|
+
releaseReplayWindow(s.pendingReplayWindow);
|
|
229
|
+
s.pendingReplayWindow = null;
|
|
1022
230
|
}
|
|
1023
231
|
function trackScreen(name) {
|
|
1024
232
|
try {
|
|
1025
233
|
const trimmed = typeof name === "string" ? name.trim() : "";
|
|
1026
234
|
if (!trimmed)
|
|
1027
235
|
return;
|
|
1028
|
-
const from = currentUrl();
|
|
1029
|
-
currentScreen = trimmed;
|
|
1030
|
-
const to = currentUrl();
|
|
236
|
+
const from = (0, client_state_1.currentUrl)(s);
|
|
237
|
+
s.currentScreen = trimmed;
|
|
238
|
+
const to = (0, client_state_1.currentUrl)(s);
|
|
1031
239
|
if (from && to && from !== to) {
|
|
1032
240
|
buffer.add((0, sdk_core_1.navigationCrumb)(from, to, now()));
|
|
1033
241
|
}
|
|
@@ -1038,17 +246,17 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
1038
246
|
}
|
|
1039
247
|
return {
|
|
1040
248
|
start,
|
|
1041
|
-
getStatus: () => status,
|
|
1042
|
-
getBoot: () => boot,
|
|
1043
|
-
canSubmit: () => status === "ready" && boot?.canSubmit === true,
|
|
249
|
+
getStatus: () => s.status,
|
|
250
|
+
getBoot: () => s.boot,
|
|
251
|
+
canSubmit: () => s.status === "ready" && s.boot?.canSubmit === true,
|
|
1044
252
|
onStatus(listener) {
|
|
1045
|
-
statusListeners.add(listener);
|
|
1046
|
-
return () => statusListeners.delete(listener);
|
|
253
|
+
s.statusListeners.add(listener);
|
|
254
|
+
return () => s.statusListeners.delete(listener);
|
|
1047
255
|
},
|
|
1048
256
|
present,
|
|
1049
257
|
onPresent(listener) {
|
|
1050
|
-
presentListeners.add(listener);
|
|
1051
|
-
return () => presentListeners.delete(listener);
|
|
258
|
+
s.presentListeners.add(listener);
|
|
259
|
+
return () => s.presentListeners.delete(listener);
|
|
1052
260
|
},
|
|
1053
261
|
trackScreen,
|
|
1054
262
|
submit,
|
|
@@ -1057,39 +265,39 @@ function createFixbackClient(options, adapters = {}) {
|
|
|
1057
265
|
shakeConfig: () => (0, shake_1.resolveShakeConfig)(options.shake),
|
|
1058
266
|
signIn,
|
|
1059
267
|
signOut,
|
|
1060
|
-
identity: () => reporterIdentity(),
|
|
268
|
+
identity: () => (0, client_state_1.reporterIdentity)(s),
|
|
1061
269
|
onIdentity(listener) {
|
|
1062
|
-
identityListeners.add(listener);
|
|
1063
|
-
return () => identityListeners.delete(listener);
|
|
270
|
+
s.identityListeners.add(listener);
|
|
271
|
+
return () => s.identityListeners.delete(listener);
|
|
1064
272
|
},
|
|
1065
|
-
maintenance: () => maintenance,
|
|
273
|
+
maintenance: () => s.maintenance,
|
|
1066
274
|
onMaintenance(listener) {
|
|
1067
|
-
maintenanceListeners.add(listener);
|
|
1068
|
-
return () => maintenanceListeners.delete(listener);
|
|
275
|
+
s.maintenanceListeners.add(listener);
|
|
276
|
+
return () => s.maintenanceListeners.delete(listener);
|
|
1069
277
|
},
|
|
1070
278
|
destroy() {
|
|
1071
279
|
// Abandon any in-flight start and allow a later start() to re-arm —
|
|
1072
280
|
// React StrictMode replays the provider's effect on this same instance.
|
|
1073
|
-
generation += 1;
|
|
1074
|
-
running = false;
|
|
281
|
+
s.generation += 1;
|
|
282
|
+
s.running = false;
|
|
1075
283
|
clearMaintenanceRecheck();
|
|
1076
|
-
maintenance = null;
|
|
284
|
+
s.maintenance = null;
|
|
1077
285
|
disarmCapture();
|
|
1078
286
|
try {
|
|
1079
|
-
teardownLinking?.();
|
|
287
|
+
s.teardownLinking?.();
|
|
1080
288
|
}
|
|
1081
289
|
catch {
|
|
1082
290
|
/* teardown is best-effort */
|
|
1083
291
|
}
|
|
1084
|
-
teardownLinking = null;
|
|
292
|
+
s.teardownLinking = null;
|
|
1085
293
|
// Nothing will redeem an inbound code now, so a `signIn()` still waiting on one
|
|
1086
294
|
// is answered rather than left pending for the life of the app.
|
|
1087
295
|
settleInboundWaiters(false);
|
|
1088
|
-
statusListeners.clear();
|
|
1089
|
-
presentListeners.clear();
|
|
1090
|
-
identityListeners.clear();
|
|
1091
|
-
maintenanceListeners.clear();
|
|
1092
|
-
setStatus("disabled");
|
|
296
|
+
s.statusListeners.clear();
|
|
297
|
+
s.presentListeners.clear();
|
|
298
|
+
s.identityListeners.clear();
|
|
299
|
+
s.maintenanceListeners.clear();
|
|
300
|
+
(0, client_state_1.setStatus)(s, "disabled");
|
|
1093
301
|
},
|
|
1094
302
|
};
|
|
1095
303
|
}
|