@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.
@@ -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
+ }