@fixback/expo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/FeedbackModal.d.ts +23 -0
- package/dist/FeedbackModal.js +201 -0
- package/dist/FixbackProvider.d.ts +35 -0
- package/dist/FixbackProvider.js +102 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +143 -0
- package/dist/auto-report-backoff.d.ts +42 -0
- package/dist/auto-report-backoff.js +67 -0
- package/dist/boot.d.ts +65 -0
- package/dist/boot.js +60 -0
- package/dist/breadcrumbs.d.ts +276 -0
- package/dist/breadcrumbs.js +731 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +351 -0
- package/dist/error-capture.d.ts +168 -0
- package/dist/error-capture.js +395 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.js +20 -0
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +10 -0
- package/dist/report.d.ts +93 -0
- package/dist/report.js +67 -0
- package/dist/scrub.d.ts +55 -0
- package/dist/scrub.js +184 -0
- package/dist/shake.d.ts +51 -0
- package/dist/shake.js +79 -0
- package/dist/submit.d.ts +98 -0
- package/dist/submit.js +154 -0
- package/dist/tokens.d.ts +41 -0
- package/dist/tokens.js +46 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +63 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless Fixback client — everything the Expo SDK does except render.
|
|
3
|
+
*
|
|
4
|
+
* `createFixbackClient` owns the lifecycle the web SDK's `init` owns (spec 0003
|
|
5
|
+
* §G ported to mobile, spec 0004): establish identity → boot → arm capture
|
|
6
|
+
* (trace instrumentation + automatic error capture) only when boot allows
|
|
7
|
+
* submission → assemble/scrub/submit reports. The React layer
|
|
8
|
+
* (`FixbackProvider`) is a thin shell over this: it feeds accelerometer
|
|
9
|
+
* samples, listens for `present` events, and renders the composer.
|
|
10
|
+
*
|
|
11
|
+
* Every platform touchpoint is an injected adapter (`FixbackAdapters`), so the
|
|
12
|
+
* whole orchestration is unit-tested without React Native. Every path is
|
|
13
|
+
* defensive: a Fixback problem leaves the host app untouched — `start` resolves
|
|
14
|
+
* to a `disabled` client instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
import { type BootAnswer } from "./boot";
|
|
17
|
+
import { type BeforeBreadcrumb, type ConsoleLike, type XhrConstructor } from "./breadcrumbs";
|
|
18
|
+
import { type AppStateLike, type ErrorUtilsLike } from "./error-capture";
|
|
19
|
+
import type { FetchLike, FormDataFactory } from "./http";
|
|
20
|
+
import { type StorageLike } from "./identity";
|
|
21
|
+
import { type EnvironmentInputs } from "./report";
|
|
22
|
+
import { type BeforeSend } from "./scrub";
|
|
23
|
+
import { type ShakeTuning } from "./shake";
|
|
24
|
+
import { type ScreenshotFile, type SubmitResult } from "./submit";
|
|
25
|
+
/** Where reports go when no `apiUrl` is configured (mirrors the web SDK). */
|
|
26
|
+
export declare const DEFAULT_API_URL = "https://api.fixback.dev";
|
|
27
|
+
/** How often the accelerometer reports while the gesture is armed. */
|
|
28
|
+
export declare const DEFAULT_SHAKE_SAMPLE_INTERVAL_MS = 80;
|
|
29
|
+
/** The shake gesture's init options: the tuning plus an off switch. */
|
|
30
|
+
export interface ShakeOptions extends Partial<ShakeTuning> {
|
|
31
|
+
/** Arm the shake gesture. Defaults to `true`. */
|
|
32
|
+
readonly enabled?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The accelerometer update interval to request, in ms. Defaults to
|
|
35
|
+
* {@link DEFAULT_SHAKE_SAMPLE_INTERVAL_MS}. The interval is global to the
|
|
36
|
+
* sensor in `expo-sensors` — the SDK only sets it when no other listener is
|
|
37
|
+
* registered, and apps that drive the accelerometer themselves should align
|
|
38
|
+
* this with their own setting.
|
|
39
|
+
*/
|
|
40
|
+
readonly sampleIntervalMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/** Everything `FixbackProvider` / `createFixbackClient` accepts. */
|
|
43
|
+
export interface FixbackOptions {
|
|
44
|
+
/** The Project's publishable key. Required. */
|
|
45
|
+
readonly key: string;
|
|
46
|
+
/**
|
|
47
|
+
* The origin this app reports as, sent as the `Origin` header on every
|
|
48
|
+
* request (spec 0004 §A). Must be an `https://` origin allowlisted on the
|
|
49
|
+
* Project — e.g. `https://com.acme.myapp` or the product's web origin.
|
|
50
|
+
*/
|
|
51
|
+
readonly origin: string;
|
|
52
|
+
/** The Fixback API base URL. Defaults to {@link DEFAULT_API_URL}. */
|
|
53
|
+
readonly apiUrl?: string;
|
|
54
|
+
/** A customer-server-minted signed identity (spec 0003 §F). */
|
|
55
|
+
readonly signedIdentity?: string;
|
|
56
|
+
/** A persisted Reporter handle, when the host app manages one. */
|
|
57
|
+
readonly reporterId?: string;
|
|
58
|
+
/** Override the SDK-managed anonymous id. */
|
|
59
|
+
readonly anonymousId?: string;
|
|
60
|
+
/** Display-only Reporter name/email riding on every submission. */
|
|
61
|
+
readonly reporterName?: string;
|
|
62
|
+
readonly reporterEmail?: string;
|
|
63
|
+
/** Per-stream capture toggles; unset streams follow the boot answer. */
|
|
64
|
+
readonly capture?: {
|
|
65
|
+
readonly console?: boolean;
|
|
66
|
+
readonly network?: boolean;
|
|
67
|
+
};
|
|
68
|
+
/** Automatic error capture. Defaults to `true` (ADR-0011). */
|
|
69
|
+
readonly autoCapture?: boolean;
|
|
70
|
+
/** Capture a screenshot when the composer opens. Defaults to `true`. */
|
|
71
|
+
readonly screenshots?: boolean;
|
|
72
|
+
/** The per-project client scrub hook (spec 0003 §C). */
|
|
73
|
+
readonly beforeSend?: BeforeSend;
|
|
74
|
+
/** Run the built-in default scrubbers. Defaults to `true`. */
|
|
75
|
+
readonly scrub?: boolean;
|
|
76
|
+
/** A per-crumb filter for the trace buffer. */
|
|
77
|
+
readonly beforeBreadcrumb?: BeforeBreadcrumb | null;
|
|
78
|
+
/** Shake gesture tuning / off switch. */
|
|
79
|
+
readonly shake?: ShakeOptions;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The platform touchpoints, as injectable seams. `createNativeAdapters()`
|
|
83
|
+
* builds the real React Native set; tests pass fakes.
|
|
84
|
+
*/
|
|
85
|
+
export interface FixbackAdapters {
|
|
86
|
+
/** Anonymous-id persistence (AsyncStorage). Absent ⇒ ephemeral ids. */
|
|
87
|
+
readonly storage?: StorageLike | null;
|
|
88
|
+
/** Reads the device environment (window size, OS description). */
|
|
89
|
+
readonly environment?: () => EnvironmentInputs;
|
|
90
|
+
/** Captures the current screen as a file reference; `null` on failure. */
|
|
91
|
+
readonly captureScreenshot?: () => Promise<ScreenshotFile | null>;
|
|
92
|
+
/** Releases a captured screenshot's tmpfile (best-effort, repeat-safe). */
|
|
93
|
+
readonly releaseScreenshot?: (uri: string) => void;
|
|
94
|
+
/** The `console` to wrap for the trace. `null` ⇒ skip. */
|
|
95
|
+
readonly consoleObj?: ConsoleLike | null;
|
|
96
|
+
/** The `XMLHttpRequest` to patch for the trace. `null` ⇒ skip. */
|
|
97
|
+
readonly xhr?: XhrConstructor | null;
|
|
98
|
+
/** The `ErrorUtils` seam for automatic error capture. `null` ⇒ skip. */
|
|
99
|
+
readonly errorUtils?: ErrorUtilsLike | null;
|
|
100
|
+
/** The `AppState` seam for occurrence flushes. */
|
|
101
|
+
readonly appState?: AppStateLike | null;
|
|
102
|
+
/** The transport seams. */
|
|
103
|
+
readonly fetchImpl?: FetchLike;
|
|
104
|
+
readonly formData?: FormDataFactory;
|
|
105
|
+
/** A development-only warning sink (silent in production builds). */
|
|
106
|
+
readonly warn?: (message: string) => void;
|
|
107
|
+
/** Clock source, injectable for tests. */
|
|
108
|
+
readonly now?: () => number;
|
|
109
|
+
}
|
|
110
|
+
/** The client's lifecycle state. */
|
|
111
|
+
export type FixbackStatus = "idle" | "starting" | "ready" | "disabled";
|
|
112
|
+
/** What a `present` listener receives — the composer's opening context. */
|
|
113
|
+
export interface ComposerContext {
|
|
114
|
+
readonly screenshot: ScreenshotFile | null;
|
|
115
|
+
}
|
|
116
|
+
/** What the composer hands back when the Reporter sends. */
|
|
117
|
+
export interface ComposerDraft {
|
|
118
|
+
readonly comment?: string;
|
|
119
|
+
/** The screenshot to attach (the presented one, unless removed). */
|
|
120
|
+
readonly screenshot?: ScreenshotFile | null;
|
|
121
|
+
}
|
|
122
|
+
/** The resolved shake configuration the provider arms the gesture with. */
|
|
123
|
+
export interface ResolvedShakeConfig extends ShakeTuning {
|
|
124
|
+
readonly enabled: boolean;
|
|
125
|
+
readonly sampleIntervalMs: number;
|
|
126
|
+
}
|
|
127
|
+
/** The headless client `FixbackProvider` drives. */
|
|
128
|
+
export interface FixbackClient {
|
|
129
|
+
/** Boot and arm capture. Idempotent; never throws. */
|
|
130
|
+
start(): Promise<void>;
|
|
131
|
+
getStatus(): FixbackStatus;
|
|
132
|
+
/** The boot answer, once one arrived (for display; trust stays server-side). */
|
|
133
|
+
getBoot(): BootAnswer | null;
|
|
134
|
+
/** Whether a submission would be accepted right now. */
|
|
135
|
+
canSubmit(): boolean;
|
|
136
|
+
/** Subscribe to status changes. Returns an unsubscribe. */
|
|
137
|
+
onStatus(listener: (status: FixbackStatus) => void): () => void;
|
|
138
|
+
/**
|
|
139
|
+
* Open the composer: capture a screenshot (unless disabled) and notify
|
|
140
|
+
* `onPresent` listeners. A no-op while the client cannot submit.
|
|
141
|
+
*/
|
|
142
|
+
present(): Promise<void>;
|
|
143
|
+
/** Subscribe to composer-open requests. Returns an unsubscribe. */
|
|
144
|
+
onPresent(listener: (context: ComposerContext) => void): () => void;
|
|
145
|
+
/**
|
|
146
|
+
* Record a screen change: a `navigation` trace crumb, and the screen the
|
|
147
|
+
* next report's `url` names (spec 0004 §D).
|
|
148
|
+
*/
|
|
149
|
+
trackScreen(name: string): void;
|
|
150
|
+
/** Assemble, scrub, and submit a manual report. Never throws. */
|
|
151
|
+
submit(draft: ComposerDraft): Promise<SubmitResult>;
|
|
152
|
+
/** Release a captured screenshot's tmpfile (best-effort, repeat-safe). */
|
|
153
|
+
discardScreenshot(screenshot: ScreenshotFile | null | undefined): void;
|
|
154
|
+
/** The resolved shake tuning for the provider to arm. */
|
|
155
|
+
shakeConfig(): ResolvedShakeConfig;
|
|
156
|
+
/** Tear down every hook. Safe to call repeatedly. */
|
|
157
|
+
destroy(): void;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Canonicalize the configured origin exactly as the server's `normalizeOrigin`
|
|
161
|
+
* does when an allowlist entry is stored (`apps/api/src/projects/origin.ts`):
|
|
162
|
+
* lowercase the scheme and host, elide default ports, and drop any userinfo,
|
|
163
|
+
* path, query, or fragment. The server compares the `Origin` header by exact
|
|
164
|
+
* string equality against those canonical rows — on the web the browser
|
|
165
|
+
* serializes the header, but here the SDK is the serializer, so without this a
|
|
166
|
+
* pasted `https://com.acme.MyApp` (bundle ids legally carry uppercase) would
|
|
167
|
+
* silently never match its own allowlist entry. Implemented without `URL`
|
|
168
|
+
* (React Native's polyfill is partial); a value that doesn't parse as an
|
|
169
|
+
* http(s) origin is kept trimmed — boot will simply refuse it. IDN hosts are
|
|
170
|
+
* not punycoded (the one divergence from the server; use the encoded form).
|
|
171
|
+
*/
|
|
172
|
+
export declare function canonicalizeOrigin(value: string): string;
|
|
173
|
+
/** Create the headless client. See the module doc. */
|
|
174
|
+
export declare function createFixbackClient(options: FixbackOptions, adapters?: FixbackAdapters): FixbackClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless Fixback client — everything the Expo SDK does except render.
|
|
3
|
+
*
|
|
4
|
+
* `createFixbackClient` owns the lifecycle the web SDK's `init` owns (spec 0003
|
|
5
|
+
* §G ported to mobile, spec 0004): establish identity → boot → arm capture
|
|
6
|
+
* (trace instrumentation + automatic error capture) only when boot allows
|
|
7
|
+
* submission → assemble/scrub/submit reports. The React layer
|
|
8
|
+
* (`FixbackProvider`) is a thin shell over this: it feeds accelerometer
|
|
9
|
+
* samples, listens for `present` events, and renders the composer.
|
|
10
|
+
*
|
|
11
|
+
* Every platform touchpoint is an injected adapter (`FixbackAdapters`), so the
|
|
12
|
+
* whole orchestration is unit-tested without React Native. Every path is
|
|
13
|
+
* defensive: a Fixback problem leaves the host app untouched — `start` resolves
|
|
14
|
+
* to a `disabled` client instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
import { requestBoot } from "./boot";
|
|
17
|
+
import { createBreadcrumbBuffer, instrumentBreadcrumbs, navigationCrumb, } from "./breadcrumbs";
|
|
18
|
+
import { installErrorCapture, } from "./error-capture";
|
|
19
|
+
import { ensureAnonymousId } from "./identity";
|
|
20
|
+
import { assembleContent, collectEnvironment, } from "./report";
|
|
21
|
+
import { runBeforeSend } from "./scrub";
|
|
22
|
+
import { DEFAULT_SHAKE_TUNING } from "./shake";
|
|
23
|
+
import { submitReport, } from "./submit";
|
|
24
|
+
import { EXPO_SDK_VERSION } from "./version";
|
|
25
|
+
/** Where reports go when no `apiUrl` is configured (mirrors the web SDK). */
|
|
26
|
+
export const DEFAULT_API_URL = "https://api.fixback.dev";
|
|
27
|
+
/** How often the accelerometer reports while the gesture is armed. */
|
|
28
|
+
export const DEFAULT_SHAKE_SAMPLE_INTERVAL_MS = 80;
|
|
29
|
+
/** Normalise a base URL: trim and drop trailing slashes. */
|
|
30
|
+
function normalizeBase(value) {
|
|
31
|
+
return value.trim().replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Canonicalize the configured origin exactly as the server's `normalizeOrigin`
|
|
35
|
+
* does when an allowlist entry is stored (`apps/api/src/projects/origin.ts`):
|
|
36
|
+
* lowercase the scheme and host, elide default ports, and drop any userinfo,
|
|
37
|
+
* path, query, or fragment. The server compares the `Origin` header by exact
|
|
38
|
+
* string equality against those canonical rows — on the web the browser
|
|
39
|
+
* serializes the header, but here the SDK is the serializer, so without this a
|
|
40
|
+
* pasted `https://com.acme.MyApp` (bundle ids legally carry uppercase) would
|
|
41
|
+
* silently never match its own allowlist entry. Implemented without `URL`
|
|
42
|
+
* (React Native's polyfill is partial); a value that doesn't parse as an
|
|
43
|
+
* http(s) origin is kept trimmed — boot will simply refuse it. IDN hosts are
|
|
44
|
+
* not punycoded (the one divergence from the server; use the encoded form).
|
|
45
|
+
*/
|
|
46
|
+
export function canonicalizeOrigin(value) {
|
|
47
|
+
const trimmed = value.trim();
|
|
48
|
+
const match = trimmed.match(/^(https?):\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
|
49
|
+
if (!match)
|
|
50
|
+
return trimmed.replace(/\/+$/, "");
|
|
51
|
+
const scheme = match[1].toLowerCase();
|
|
52
|
+
let authority = match[2];
|
|
53
|
+
const at = authority.lastIndexOf("@");
|
|
54
|
+
if (at >= 0)
|
|
55
|
+
authority = authority.slice(at + 1);
|
|
56
|
+
let host = authority;
|
|
57
|
+
let port;
|
|
58
|
+
const portMatch = authority.match(/^(.+):(\d+)$/);
|
|
59
|
+
if (portMatch) {
|
|
60
|
+
host = portMatch[1];
|
|
61
|
+
port = portMatch[2];
|
|
62
|
+
}
|
|
63
|
+
host = host.toLowerCase();
|
|
64
|
+
const isDefaultPort = !port || (scheme === "https" && port === "443") || (scheme === "http" && port === "80");
|
|
65
|
+
return isDefaultPort ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
|
|
66
|
+
}
|
|
67
|
+
/** Turn a screen name into the path segment of the report `url`. */
|
|
68
|
+
function screenPath(name) {
|
|
69
|
+
return name.trim().replace(/^\/+/, "");
|
|
70
|
+
}
|
|
71
|
+
/** Create the headless client. See the module doc. */
|
|
72
|
+
export function createFixbackClient(options, adapters = {}) {
|
|
73
|
+
const key = typeof options.key === "string" ? options.key.trim() : "";
|
|
74
|
+
const origin = typeof options.origin === "string" ? canonicalizeOrigin(options.origin) : "";
|
|
75
|
+
const apiUrl = normalizeBase(options.apiUrl || DEFAULT_API_URL) || DEFAULT_API_URL;
|
|
76
|
+
const now = adapters.now ?? Date.now;
|
|
77
|
+
const readEnvironment = adapters.environment ?? (() => ({}));
|
|
78
|
+
const warn = adapters.warn ?? (() => { });
|
|
79
|
+
const buffer = createBreadcrumbBuffer({
|
|
80
|
+
beforeBreadcrumb: options.beforeBreadcrumb,
|
|
81
|
+
now,
|
|
82
|
+
});
|
|
83
|
+
let status = "idle";
|
|
84
|
+
let boot = null;
|
|
85
|
+
let currentScreen = null;
|
|
86
|
+
let teardownTrace = null;
|
|
87
|
+
let autoCapture = null;
|
|
88
|
+
let identity = {};
|
|
89
|
+
/**
|
|
90
|
+
* The lifecycle generation. Each `start()` claims a new generation and every
|
|
91
|
+
* await inside it re-checks that it is still current; `destroy()` bumps the
|
|
92
|
+
* generation so an in-flight start abandons itself. A destroyed client can be
|
|
93
|
+
* started again — React 18+ StrictMode (and Fast Refresh) replay the
|
|
94
|
+
* provider's effect as mount → cleanup → re-mount on the same client
|
|
95
|
+
* instance, so destroy must not be terminal.
|
|
96
|
+
*/
|
|
97
|
+
let generation = 0;
|
|
98
|
+
/** Whether a start() has claimed the current generation. */
|
|
99
|
+
let running = false;
|
|
100
|
+
const statusListeners = new Set();
|
|
101
|
+
const presentListeners = new Set();
|
|
102
|
+
function setStatus(next) {
|
|
103
|
+
if (status === next)
|
|
104
|
+
return;
|
|
105
|
+
status = next;
|
|
106
|
+
for (const listener of statusListeners) {
|
|
107
|
+
try {
|
|
108
|
+
listener(next);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* a listener must never break the client */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The `url` a report carries right now (spec 0004 §D). */
|
|
116
|
+
function currentUrl() {
|
|
117
|
+
if (!origin)
|
|
118
|
+
return undefined;
|
|
119
|
+
return currentScreen ? `${origin}/${screenPath(currentScreen)}` : origin;
|
|
120
|
+
}
|
|
121
|
+
/** Keep the SDK's own ingest traffic out of the trace. */
|
|
122
|
+
function isFixbackApiRequest(url) {
|
|
123
|
+
return url.startsWith(`${apiUrl}/api/ingest/`) || url.startsWith(`${apiUrl}/api/invites/`);
|
|
124
|
+
}
|
|
125
|
+
async function start() {
|
|
126
|
+
if (running)
|
|
127
|
+
return;
|
|
128
|
+
running = true;
|
|
129
|
+
const gen = ++generation;
|
|
130
|
+
setStatus("starting");
|
|
131
|
+
try {
|
|
132
|
+
if (!key || !origin) {
|
|
133
|
+
if (!key)
|
|
134
|
+
warn("Fixback: init needs a publishable `key`; staying dormant.");
|
|
135
|
+
if (!origin) {
|
|
136
|
+
warn("Fixback: init needs an `origin` (an https origin allowlisted on the Project) — a native app sends no Origin header of its own; staying dormant.");
|
|
137
|
+
}
|
|
138
|
+
setStatus("disabled");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const anonymousId = options.anonymousId ?? (await ensureAnonymousId(adapters.storage));
|
|
142
|
+
if (gen !== generation)
|
|
143
|
+
return; // destroyed (or restarted) while resolving
|
|
144
|
+
identity = {
|
|
145
|
+
...(options.signedIdentity ? { signedIdentity: options.signedIdentity } : {}),
|
|
146
|
+
...(options.reporterId ? { reporterId: options.reporterId } : {}),
|
|
147
|
+
anonymousId,
|
|
148
|
+
};
|
|
149
|
+
const answer = await requestBoot(apiUrl, { key, ...identity }, origin, adapters.fetchImpl);
|
|
150
|
+
if (gen !== generation)
|
|
151
|
+
return; // destroyed (or restarted) while booting
|
|
152
|
+
boot = answer;
|
|
153
|
+
if (!answer || !answer.canSubmit) {
|
|
154
|
+
if (answer && !answer.originAllowed) {
|
|
155
|
+
warn(`Fixback: the Project has not allowlisted "${origin}" — add it to the Project's allowed origins to enable feedback from this app.`);
|
|
156
|
+
}
|
|
157
|
+
setStatus("disabled");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Per-stream capture: an init option overrides the served config; an
|
|
161
|
+
// absent/malformed served value means ON (spec 0003 §C, ported).
|
|
162
|
+
const served = answer.capture;
|
|
163
|
+
const captureConsole = options.capture?.console ??
|
|
164
|
+
(typeof served?.console === "boolean" ? served.console : true);
|
|
165
|
+
const captureNetwork = options.capture?.network ??
|
|
166
|
+
(typeof served?.network === "boolean" ? served.network : true);
|
|
167
|
+
teardownTrace = instrumentBreadcrumbs(buffer, {
|
|
168
|
+
consoleObj: adapters.consoleObj,
|
|
169
|
+
xhr: adapters.xhr,
|
|
170
|
+
ignoreUrl: isFixbackApiRequest,
|
|
171
|
+
now,
|
|
172
|
+
captureConsole,
|
|
173
|
+
captureNetwork,
|
|
174
|
+
});
|
|
175
|
+
if (options.autoCapture !== false) {
|
|
176
|
+
autoCapture = installErrorCapture({
|
|
177
|
+
apiUrl,
|
|
178
|
+
key,
|
|
179
|
+
origin,
|
|
180
|
+
identity,
|
|
181
|
+
display: {
|
|
182
|
+
name: options.reporterName,
|
|
183
|
+
email: options.reporterEmail,
|
|
184
|
+
},
|
|
185
|
+
sdkVersion: EXPO_SDK_VERSION,
|
|
186
|
+
buffer,
|
|
187
|
+
beforeSend: options.beforeSend,
|
|
188
|
+
scrub: options.scrub,
|
|
189
|
+
environment: readEnvironment,
|
|
190
|
+
currentUrl,
|
|
191
|
+
errorUtils: adapters.errorUtils,
|
|
192
|
+
appState: adapters.appState,
|
|
193
|
+
deps: {
|
|
194
|
+
submitReport: (url, input) => submitReport(url, input, {
|
|
195
|
+
fetchImpl: adapters.fetchImpl,
|
|
196
|
+
formData: adapters.formData,
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
now,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
setStatus("ready");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// A Fixback problem must never disturb the host app.
|
|
206
|
+
if (gen === generation)
|
|
207
|
+
setStatus("disabled");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function present() {
|
|
211
|
+
if (status !== "ready")
|
|
212
|
+
return;
|
|
213
|
+
let screenshot = null;
|
|
214
|
+
if (options.screenshots !== false && adapters.captureScreenshot) {
|
|
215
|
+
try {
|
|
216
|
+
screenshot = await adapters.captureScreenshot();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
screenshot = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const context = { screenshot };
|
|
223
|
+
for (const listener of presentListeners) {
|
|
224
|
+
try {
|
|
225
|
+
listener(context);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* a listener must never break the client */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function submit(draft) {
|
|
233
|
+
try {
|
|
234
|
+
const content = runBeforeSend(
|
|
235
|
+
// No Kind on the wire — analysis classifies the Issue server-side
|
|
236
|
+
// (ADR-0023).
|
|
237
|
+
assembleContent({
|
|
238
|
+
comment: draft.comment,
|
|
239
|
+
url: currentUrl(),
|
|
240
|
+
environment: collectEnvironment(readEnvironment(), EXPO_SDK_VERSION),
|
|
241
|
+
trace: buffer.snapshot(),
|
|
242
|
+
reporterName: options.reporterName,
|
|
243
|
+
reporterEmail: options.reporterEmail,
|
|
244
|
+
}), { hook: options.beforeSend, scrub: options.scrub });
|
|
245
|
+
// Dropped by the project's own beforeSend — an intentional non-send.
|
|
246
|
+
if (!content)
|
|
247
|
+
return { ok: true, feedback: null };
|
|
248
|
+
const result = await submitReport(apiUrl, {
|
|
249
|
+
key,
|
|
250
|
+
origin,
|
|
251
|
+
identity,
|
|
252
|
+
content,
|
|
253
|
+
screenshot: draft.screenshot ?? null,
|
|
254
|
+
}, { fetchImpl: adapters.fetchImpl, formData: adapters.formData });
|
|
255
|
+
// A delivered screenshot's tmpfile has served its purpose — release it.
|
|
256
|
+
// On a failure the file is kept: the composer holds the draft for retry.
|
|
257
|
+
if (result.ok)
|
|
258
|
+
discardScreenshot(draft.screenshot);
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return { ok: false, reason: "unreachable" };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Release a captured screenshot's tmpfile (best-effort, repeat-safe). Called
|
|
267
|
+
* after a delivered submission, and by the UI when the Reporter removes the
|
|
268
|
+
* screenshot or discards the draft — an image the Reporter declined to send
|
|
269
|
+
* must not linger in the app's cache (spec 0004 §C).
|
|
270
|
+
*/
|
|
271
|
+
function discardScreenshot(screenshot) {
|
|
272
|
+
if (!screenshot?.uri || !adapters.releaseScreenshot)
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
adapters.releaseScreenshot(screenshot.uri);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* release is best-effort */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function trackScreen(name) {
|
|
282
|
+
try {
|
|
283
|
+
const trimmed = typeof name === "string" ? name.trim() : "";
|
|
284
|
+
if (!trimmed)
|
|
285
|
+
return;
|
|
286
|
+
const from = currentUrl();
|
|
287
|
+
currentScreen = trimmed;
|
|
288
|
+
const to = currentUrl();
|
|
289
|
+
if (from && to && from !== to) {
|
|
290
|
+
buffer.add(navigationCrumb(from, to, now()));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
/* never throw into the host app */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function shakeConfig() {
|
|
298
|
+
const shake = options.shake ?? {};
|
|
299
|
+
return {
|
|
300
|
+
enabled: shake.enabled !== false,
|
|
301
|
+
sampleIntervalMs: shake.sampleIntervalMs ?? DEFAULT_SHAKE_SAMPLE_INTERVAL_MS,
|
|
302
|
+
thresholdG: shake.thresholdG ?? DEFAULT_SHAKE_TUNING.thresholdG,
|
|
303
|
+
minPeaks: shake.minPeaks ?? DEFAULT_SHAKE_TUNING.minPeaks,
|
|
304
|
+
windowMs: shake.windowMs ?? DEFAULT_SHAKE_TUNING.windowMs,
|
|
305
|
+
minGapMs: shake.minGapMs ?? DEFAULT_SHAKE_TUNING.minGapMs,
|
|
306
|
+
cooldownMs: shake.cooldownMs ?? DEFAULT_SHAKE_TUNING.cooldownMs,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
start,
|
|
311
|
+
getStatus: () => status,
|
|
312
|
+
getBoot: () => boot,
|
|
313
|
+
canSubmit: () => status === "ready" && boot?.canSubmit === true,
|
|
314
|
+
onStatus(listener) {
|
|
315
|
+
statusListeners.add(listener);
|
|
316
|
+
return () => statusListeners.delete(listener);
|
|
317
|
+
},
|
|
318
|
+
present,
|
|
319
|
+
onPresent(listener) {
|
|
320
|
+
presentListeners.add(listener);
|
|
321
|
+
return () => presentListeners.delete(listener);
|
|
322
|
+
},
|
|
323
|
+
trackScreen,
|
|
324
|
+
submit,
|
|
325
|
+
discardScreenshot,
|
|
326
|
+
shakeConfig,
|
|
327
|
+
destroy() {
|
|
328
|
+
// Abandon any in-flight start and allow a later start() to re-arm —
|
|
329
|
+
// React StrictMode replays the provider's effect on this same instance.
|
|
330
|
+
generation += 1;
|
|
331
|
+
running = false;
|
|
332
|
+
try {
|
|
333
|
+
teardownTrace?.();
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* teardown is best-effort */
|
|
337
|
+
}
|
|
338
|
+
teardownTrace = null;
|
|
339
|
+
try {
|
|
340
|
+
autoCapture?.destroy();
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* teardown is best-effort */
|
|
344
|
+
}
|
|
345
|
+
autoCapture = null;
|
|
346
|
+
statusListeners.clear();
|
|
347
|
+
presentListeners.clear();
|
|
348
|
+
setStatus("disabled");
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|