@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,46 @@
1
+ /**
2
+ * The Public Reporter's stable, first-party anonymous id — the mobile
3
+ * counterpart of `packages/sdk/src/identity.ts`.
4
+ *
5
+ * When the host app does not supply its own identity, the SDK establishes a
6
+ * per-device id and persists it (AsyncStorage on React Native), so repeated
7
+ * boots from the same device are one Reporter. Persistence is best-effort: if
8
+ * storage is unavailable or failing, we fall back to an ephemeral id rather
9
+ * than throw — the SDK must never disturb the host app. The storage key is the
10
+ * web SDK's, so the shape stays recognisable across clients.
11
+ */
12
+ export const ANONYMOUS_ID_STORAGE_KEY = "fixback.anonymousId";
13
+ /** A random id: a UUID where the platform offers one, else a compact fallback. */
14
+ export function generateAnonymousId() {
15
+ const c = globalThis.crypto;
16
+ if (c && typeof c.randomUUID === "function") {
17
+ try {
18
+ return c.randomUUID();
19
+ }
20
+ catch {
21
+ // Fall through to the compact fallback.
22
+ }
23
+ }
24
+ return `fb-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
25
+ }
26
+ /**
27
+ * Return this device's anonymous id, creating and persisting one on first use.
28
+ * Any storage failure — absent module, quota, corrupted value — yields an
29
+ * ephemeral id for this session instead of an error.
30
+ */
31
+ export async function ensureAnonymousId(storage) {
32
+ if (!storage)
33
+ return generateAnonymousId();
34
+ try {
35
+ const existing = await storage.getItem(ANONYMOUS_ID_STORAGE_KEY);
36
+ if (typeof existing === "string" && existing.length > 0)
37
+ return existing;
38
+ const id = generateAnonymousId();
39
+ await storage.setItem(ANONYMOUS_ID_STORAGE_KEY, id);
40
+ return id;
41
+ }
42
+ catch {
43
+ // Storage blocked or absent — an ephemeral id keeps this boot working.
44
+ return generateAnonymousId();
45
+ }
46
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @fixback/expo — the Fixback capture SDK for Expo / React Native apps.
3
+ *
4
+ * Shake the device to report feedback with trace Evidence; uncaught errors
5
+ * report themselves. Spec 0004; decision record ADR-0021.
6
+ */
7
+ export { Fixback, FixbackProvider, useFixback, type FixbackHandle, type FixbackProviderProps, } from "./FixbackProvider";
8
+ export { canonicalizeOrigin, createFixbackClient, DEFAULT_API_URL, DEFAULT_SHAKE_SAMPLE_INTERVAL_MS, type ComposerContext, type ComposerDraft, type FixbackAdapters, type FixbackClient, type FixbackOptions, type FixbackStatus, type ShakeOptions, } from "./client";
9
+ export type { BootAnswer, CaptureConfig, IdentityInputs, ProjectGate, ReporterTier, } from "./boot";
10
+ export type { CaptureEnvironment, FeedbackSource, ReportContent, } from "./report";
11
+ export type { Breadcrumb, BreadcrumbCategory, BreadcrumbLevel, BeforeBreadcrumb, } from "./breadcrumbs";
12
+ export type { BeforeSend } from "./scrub";
13
+ export { createShakeDetector, DEFAULT_SHAKE_TUNING, type ShakeDetector, type ShakeSample, type ShakeTuning, } from "./shake";
14
+ export type { RecordedFeedback, ScreenshotFile, SubmitResult, } from "./submit";
15
+ export { EXPO_SDK_VERSION } from "./version";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @fixback/expo — the Fixback capture SDK for Expo / React Native apps.
3
+ *
4
+ * Shake the device to report feedback with trace Evidence; uncaught errors
5
+ * report themselves. Spec 0004; decision record ADR-0021.
6
+ */
7
+ export { Fixback, FixbackProvider, useFixback, } from "./FixbackProvider";
8
+ export { canonicalizeOrigin, createFixbackClient, DEFAULT_API_URL, DEFAULT_SHAKE_SAMPLE_INTERVAL_MS, } from "./client";
9
+ export { createShakeDetector, DEFAULT_SHAKE_TUNING, } from "./shake";
10
+ export { EXPO_SDK_VERSION } from "./version";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The ingest **feedback** content wire-contract, vendored, plus the helpers
3
+ * that assemble it from what a Reporter composed — kept file-parallel with
4
+ * `packages/sdk/src/report.ts` (ADR-0021). The mobile slice carries no
5
+ * `annotation` (element picking and drawing are web-only today, spec 0004 §G);
6
+ * every content field is optional on the server, so the payload simply omits it.
7
+ */
8
+ import type { Breadcrumb } from "./breadcrumbs";
9
+ /**
10
+ * Where a Feedback came from — a human in the composer (`reporter`, the
11
+ * default) or the SDK's automatic error capture (`auto`). Mirrors the server's
12
+ * `FEEDBACK_SOURCES`; the server derives trust independently.
13
+ */
14
+ export type FeedbackSource = "reporter" | "auto";
15
+ /** The capture environment recorded alongside a report. */
16
+ export interface CaptureEnvironment {
17
+ readonly viewportWidth?: number;
18
+ readonly viewportHeight?: number;
19
+ readonly browser?: string;
20
+ readonly sdkVersion?: string;
21
+ }
22
+ /**
23
+ * The JSON content of a feedback submission — the object serialised into the
24
+ * multipart `payload` part next to the `key` and identity evidence. Every
25
+ * field is optional: none of it feeds the server's trust decision. The
26
+ * screenshot is a separate binary part, never part of this JSON.
27
+ */
28
+ export interface ReportContent {
29
+ readonly comment?: string;
30
+ /**
31
+ * The Reporter's self-provided display **name / email** — display only,
32
+ * never a trust signal; the server derives the tier from identity evidence
33
+ * alone and ignores these.
34
+ */
35
+ readonly reporterName?: string;
36
+ readonly reporterEmail?: string;
37
+ /** On mobile: `<origin>/<screen>` when a screen was tracked, else the origin. */
38
+ readonly url?: string;
39
+ readonly environment?: CaptureEnvironment;
40
+ /** The masked breadcrumb trace buffer that rode on this report. */
41
+ readonly trace?: readonly Breadcrumb[];
42
+ /**
43
+ * Provenance. Omitted for a manual report — the transport stamps the
44
+ * `reporter` default on the wire; set to `auto` by the SDK's error capture.
45
+ */
46
+ readonly source?: FeedbackSource;
47
+ /** For `source: auto` only — the SDK's per-session error fingerprint. */
48
+ readonly errorSignature?: string;
49
+ /** For `source: auto` only — the running occurrence count within the session. */
50
+ readonly occurrences?: number;
51
+ }
52
+ /** What the composer hands to {@link assembleContent} when the Reporter sends. */
53
+ export interface ReportDraft {
54
+ readonly comment?: string;
55
+ readonly url?: string;
56
+ readonly environment?: CaptureEnvironment;
57
+ readonly trace?: readonly Breadcrumb[];
58
+ /** The Reporter's self-provided display name / email, if the SDK holds them. */
59
+ readonly reporterName?: string;
60
+ readonly reporterEmail?: string;
61
+ }
62
+ /** What the platform adapter reads off the device for the environment. */
63
+ export interface EnvironmentInputs {
64
+ /** The app window's dimensions in points, from `Dimensions.get("window")`. */
65
+ readonly windowWidth?: number;
66
+ readonly windowHeight?: number;
67
+ /** The platform description, e.g. `ios 17.4` (`Platform.OS` + `Platform.Version`). */
68
+ readonly os?: string;
69
+ }
70
+ /**
71
+ * Build the capture environment from what the adapter read off the device. A
72
+ * dimension is recorded only when it is a positive number (the server rejects
73
+ * `0` via `int().positive()`); the `browser` field — the server's free-form
74
+ * client string — carries a `react-native <os>` description.
75
+ */
76
+ export declare function collectEnvironment(inputs: EnvironmentInputs, sdkVersion: string): CaptureEnvironment;
77
+ /**
78
+ * The server's hard bounds on the display-only reporter fields
79
+ * (`feedbackContentBody` in `apps/api/src/ingest/ingest.controller.ts`). An
80
+ * over-long value fails the whole submission with a 400 there, and unlike the
81
+ * web SDK — whose onboarding input is the source — these arrive as free-form
82
+ * init options, so the client clamps rather than lets one oversized display
83
+ * string swallow every report.
84
+ */
85
+ export declare const MAX_REPORTER_NAME_LENGTH = 200;
86
+ export declare const MAX_REPORTER_EMAIL_LENGTH = 320;
87
+ /**
88
+ * Assemble the ingest content from what the Reporter composed. Empty pieces
89
+ * are dropped rather than sent as blanks: a whitespace-only comment or an
90
+ * empty environment is simply omitted, so the payload carries only what was
91
+ * actually provided (mirroring the server's all-optional content).
92
+ */
93
+ export declare function assembleContent(draft: ReportDraft): ReportContent;
package/dist/report.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The ingest **feedback** content wire-contract, vendored, plus the helpers
3
+ * that assemble it from what a Reporter composed — kept file-parallel with
4
+ * `packages/sdk/src/report.ts` (ADR-0021). The mobile slice carries no
5
+ * `annotation` (element picking and drawing are web-only today, spec 0004 §G);
6
+ * every content field is optional on the server, so the payload simply omits it.
7
+ */
8
+ /**
9
+ * Build the capture environment from what the adapter read off the device. A
10
+ * dimension is recorded only when it is a positive number (the server rejects
11
+ * `0` via `int().positive()`); the `browser` field — the server's free-form
12
+ * client string — carries a `react-native <os>` description.
13
+ */
14
+ export function collectEnvironment(inputs, sdkVersion) {
15
+ const env = {};
16
+ const width = inputs.windowWidth;
17
+ if (typeof width === "number" && width > 0)
18
+ env.viewportWidth = Math.round(width);
19
+ const height = inputs.windowHeight;
20
+ if (typeof height === "number" && height > 0) {
21
+ env.viewportHeight = Math.round(height);
22
+ }
23
+ const os = typeof inputs.os === "string" ? inputs.os.trim() : "";
24
+ env.browser = os ? `react-native ${os}` : "react-native";
25
+ if (sdkVersion.length > 0)
26
+ env.sdkVersion = sdkVersion;
27
+ return env;
28
+ }
29
+ /**
30
+ * The server's hard bounds on the display-only reporter fields
31
+ * (`feedbackContentBody` in `apps/api/src/ingest/ingest.controller.ts`). An
32
+ * over-long value fails the whole submission with a 400 there, and unlike the
33
+ * web SDK — whose onboarding input is the source — these arrive as free-form
34
+ * init options, so the client clamps rather than lets one oversized display
35
+ * string swallow every report.
36
+ */
37
+ export const MAX_REPORTER_NAME_LENGTH = 200;
38
+ export const MAX_REPORTER_EMAIL_LENGTH = 320;
39
+ /**
40
+ * Assemble the ingest content from what the Reporter composed. Empty pieces
41
+ * are dropped rather than sent as blanks: a whitespace-only comment or an
42
+ * empty environment is simply omitted, so the payload carries only what was
43
+ * actually provided (mirroring the server's all-optional content).
44
+ */
45
+ export function assembleContent(draft) {
46
+ const content = {};
47
+ const comment = draft.comment?.trim();
48
+ if (comment)
49
+ content.comment = comment;
50
+ const reporterName = draft.reporterName?.trim();
51
+ if (reporterName) {
52
+ content.reporterName = reporterName.slice(0, MAX_REPORTER_NAME_LENGTH);
53
+ }
54
+ const reporterEmail = draft.reporterEmail?.trim();
55
+ if (reporterEmail) {
56
+ content.reporterEmail = reporterEmail.slice(0, MAX_REPORTER_EMAIL_LENGTH);
57
+ }
58
+ if (draft.url)
59
+ content.url = draft.url;
60
+ if (draft.environment && Object.keys(draft.environment).length > 0) {
61
+ content.environment = draft.environment;
62
+ }
63
+ // Only a non-empty trace rides along — an empty buffer is simply omitted.
64
+ if (draft.trace && draft.trace.length > 0)
65
+ content.trace = draft.trace;
66
+ return content;
67
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The single client-side **scrub choke point** every report passes through
3
+ * before transport — a verbatim port of `packages/sdk/src/scrub.ts` (spec 0003
4
+ * §C; ADR-0021 keeps the two clients file-parallel).
5
+ *
6
+ * Masking is the SDK's job, done on the device before anything leaves it. The
7
+ * default scrubbers are **on**: they strip credentials, query strings, and
8
+ * bearer tokens from URLs, and redact obvious PII (emails, long digit runs,
9
+ * bearer tokens) from crumb and error text. The result is then handed to an
10
+ * optional per-project hook that can mutate it further or drop the whole report
11
+ * by returning `null`. The hook is **synchronous and network-free** by
12
+ * contract, and both manual (composer) and automatic (error-capture) reports
13
+ * run through the very same choke point.
14
+ */
15
+ import type { ReportContent } from "./report";
16
+ /** The per-project client scrub hook. Return `null` to drop the whole report. */
17
+ export type BeforeSend = (draft: ReportContent) => ReportContent | null;
18
+ /** Options for {@link runBeforeSend}. */
19
+ export interface BeforeSendOptions {
20
+ /** The per-project hook, run **after** the default scrubbers. */
21
+ readonly hook?: BeforeSend | null;
22
+ /** Run the built-in default scrubbers first. Defaults to `true`. */
23
+ readonly scrub?: boolean;
24
+ }
25
+ /**
26
+ * Redact obvious PII from free text: email addresses, `Bearer <token>` /
27
+ * `token <value>` pairs, and long digit runs. Conservative by design — it keeps
28
+ * the shape of the message readable while removing the sensitive spans.
29
+ */
30
+ export declare function redactPii(text: string): string;
31
+ /**
32
+ * Strip the sensitive parts of a URL: userinfo credentials, the entire query
33
+ * string, a token-bearing fragment (one that carries `key=value`), and PII in
34
+ * the **path segments**. Plain hash routes (`#/checkout`) are kept. Works on
35
+ * absolute and relative URLs alike, with no dependency and no throw. The host
36
+ * (authority) is never redacted.
37
+ */
38
+ export declare function scrubUrl(url: string): string;
39
+ /**
40
+ * Apply the built-in default scrubbers to a report draft: strip the report URL,
41
+ * and scrub every crumb's URLs and redact PII from its text. The Reporter's own
42
+ * `comment` is intentionally left untouched — it is authored on purpose, not
43
+ * scraped. The screenshot is handled elsewhere (composer preview + removal,
44
+ * spec 0004 §C); this is the final URL/PII sweep.
45
+ */
46
+ export declare function applyDefaultScrub(draft: ReportContent): ReportContent;
47
+ /**
48
+ * Run the report draft through the client scrub choke point: the default
49
+ * scrubbers first (unless `scrub` is `false`), then the optional per-project
50
+ * hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
51
+ * the hook drops the report. A hook that throws is treated as a no-op — the
52
+ * already-scrubbed draft is kept, so a buggy hook never breaks the report path
53
+ * nor leaks unscrubbed data.
54
+ */
55
+ export declare function runBeforeSend(draft: ReportContent, options?: BeforeSendOptions): ReportContent | null;
package/dist/scrub.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The single client-side **scrub choke point** every report passes through
3
+ * before transport — a verbatim port of `packages/sdk/src/scrub.ts` (spec 0003
4
+ * §C; ADR-0021 keeps the two clients file-parallel).
5
+ *
6
+ * Masking is the SDK's job, done on the device before anything leaves it. The
7
+ * default scrubbers are **on**: they strip credentials, query strings, and
8
+ * bearer tokens from URLs, and redact obvious PII (emails, long digit runs,
9
+ * bearer tokens) from crumb and error text. The result is then handed to an
10
+ * optional per-project hook that can mutate it further or drop the whole report
11
+ * by returning `null`. The hook is **synchronous and network-free** by
12
+ * contract, and both manual (composer) and automatic (error-capture) reports
13
+ * run through the very same choke point.
14
+ */
15
+ /** A digit run at least this long is treated as sensitive (phone, card, id). */
16
+ const MIN_DIGIT_RUN = 7;
17
+ const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
18
+ const DIGIT_RUN_RE = new RegExp(`\\d{${MIN_DIGIT_RUN},}`, "g");
19
+ const BEARER_RE = /\b(bearer|token)\s+[\w.\-~+/]+=*/gi;
20
+ /** An http(s) URL embedded in free text (e.g. a console log line). */
21
+ const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s"'<>]+/gi;
22
+ /**
23
+ * Redact obvious PII from free text: email addresses, `Bearer <token>` /
24
+ * `token <value>` pairs, and long digit runs. Conservative by design — it keeps
25
+ * the shape of the message readable while removing the sensitive spans.
26
+ */
27
+ export function redactPii(text) {
28
+ if (typeof text !== "string" || text.length === 0)
29
+ return text;
30
+ return text
31
+ .replace(EMAIL_RE, "[redacted-email]")
32
+ .replace(BEARER_RE, (_match, keyword) => `${keyword} [redacted]`)
33
+ .replace(DIGIT_RUN_RE, "[redacted-number]");
34
+ }
35
+ /**
36
+ * Redact PII from the **path** portion of a URL: emails and long digit runs in
37
+ * path (or fragment-route) segments. Deliberately narrower than
38
+ * {@link redactPii}: no bearer/token rule and only ever applied to the
39
+ * post-authority remainder, so a host or port with digits is never touched.
40
+ */
41
+ function redactPathPii(pathAndBeyond) {
42
+ return pathAndBeyond
43
+ .replace(EMAIL_RE, "[redacted-email]")
44
+ .replace(DIGIT_RUN_RE, "[redacted-number]");
45
+ }
46
+ /**
47
+ * Strip the sensitive parts of a URL: userinfo credentials, the entire query
48
+ * string, a token-bearing fragment (one that carries `key=value`), and PII in
49
+ * the **path segments**. Plain hash routes (`#/checkout`) are kept. Works on
50
+ * absolute and relative URLs alike, with no dependency and no throw. The host
51
+ * (authority) is never redacted.
52
+ */
53
+ export function scrubUrl(url) {
54
+ if (typeof url !== "string" || url.length === 0)
55
+ return url;
56
+ let out = url;
57
+ // Drop userinfo credentials: scheme://user:pass@host → scheme://host
58
+ out = out.replace(/(^[a-z][a-z0-9+.-]*:\/\/)[^/@?#]*@/i, "$1");
59
+ // Drop the query string entirely (everything from '?' up to a '#').
60
+ out = out.replace(/\?[^#]*/, "");
61
+ // Drop a token-bearing fragment (it carries '='); keep plain hash routes.
62
+ out = out.replace(/#.*$/, (fragment) => fragment.includes("=") ? "" : fragment);
63
+ // Redact PII in the path (and any kept fragment route), never in the authority.
64
+ const absolute = out.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)([\s\S]*)$/i);
65
+ if (absolute)
66
+ return absolute[1] + redactPathPii(absolute[2] ?? "");
67
+ const protocolRelative = out.match(/^(\/\/[^/?#]*)([\s\S]*)$/);
68
+ if (protocolRelative) {
69
+ return protocolRelative[1] + redactPathPii(protocolRelative[2] ?? "");
70
+ }
71
+ // A relative URL is all path — redact the whole thing.
72
+ return redactPathPii(out);
73
+ }
74
+ function scrubCrumbData(data) {
75
+ const next = { ...data };
76
+ if (typeof next.url === "string")
77
+ next.url = scrubUrl(next.url);
78
+ if (typeof next.from === "string")
79
+ next.from = scrubUrl(next.from);
80
+ if (typeof next.to === "string")
81
+ next.to = scrubUrl(next.to);
82
+ return next;
83
+ }
84
+ /** Scrub a crumb's free-text message: strip URL query strings, then redact PII. */
85
+ function scrubMessage(message) {
86
+ return redactPii(message.replace(URL_IN_TEXT_RE, (url) => scrubUrl(url)));
87
+ }
88
+ /**
89
+ * Scrub the string leaves of a structured console-arg value — the value a
90
+ * `json` arg carries. Walks arrays and plain objects, applying the same
91
+ * URL-strip + PII redaction as a crumb message to every nested string.
92
+ */
93
+ function scrubArgValue(value) {
94
+ if (typeof value === "string")
95
+ return scrubMessage(value);
96
+ if (Array.isArray(value))
97
+ return value.map(scrubArgValue);
98
+ if (value && typeof value === "object") {
99
+ const out = {};
100
+ for (const [key, v] of Object.entries(value)) {
101
+ out[key] = scrubArgValue(v);
102
+ }
103
+ return out;
104
+ }
105
+ return value;
106
+ }
107
+ /**
108
+ * Scrub one structured console argument: redact PII / strip URL query strings
109
+ * from a `string` value, from every string leaf of a `json` value, and from an
110
+ * `error` value's `message` and `stack`. Other tags carry no free text.
111
+ */
112
+ function scrubConsoleArg(arg) {
113
+ switch (arg.t) {
114
+ case "string":
115
+ return typeof arg.v === "string" ? { t: "string", v: scrubMessage(arg.v) } : arg;
116
+ case "json":
117
+ return { t: "json", v: scrubArgValue(arg.v) };
118
+ case "error": {
119
+ if (!arg.v || typeof arg.v !== "object")
120
+ return arg;
121
+ const v = arg.v;
122
+ const next = { ...v };
123
+ if (typeof next.message === "string")
124
+ next.message = scrubMessage(next.message);
125
+ if (typeof next.stack === "string")
126
+ next.stack = scrubMessage(next.stack);
127
+ return { t: "error", v: next };
128
+ }
129
+ default:
130
+ return arg;
131
+ }
132
+ }
133
+ function scrubCrumb(crumb) {
134
+ const next = { ...crumb };
135
+ if (typeof next.message === "string")
136
+ next.message = scrubMessage(next.message);
137
+ if (next.data)
138
+ next.data = scrubCrumbData(next.data);
139
+ // A rich network crumb carries its scrubbed URL at the top level — sweep it
140
+ // again here (idempotent) so the choke point holds wherever the URL rode.
141
+ if (typeof next.url === "string")
142
+ next.url = scrubUrl(next.url);
143
+ // A console crumb's structured args carry the same free text as its preview.
144
+ if (Array.isArray(next.args))
145
+ next.args = next.args.map(scrubConsoleArg);
146
+ return next;
147
+ }
148
+ /**
149
+ * Apply the built-in default scrubbers to a report draft: strip the report URL,
150
+ * and scrub every crumb's URLs and redact PII from its text. The Reporter's own
151
+ * `comment` is intentionally left untouched — it is authored on purpose, not
152
+ * scraped. The screenshot is handled elsewhere (composer preview + removal,
153
+ * spec 0004 §C); this is the final URL/PII sweep.
154
+ */
155
+ export function applyDefaultScrub(draft) {
156
+ const next = { ...draft };
157
+ if (typeof next.url === "string")
158
+ next.url = scrubUrl(next.url);
159
+ if (next.trace && next.trace.length > 0) {
160
+ next.trace = next.trace.map(scrubCrumb);
161
+ }
162
+ return next;
163
+ }
164
+ /**
165
+ * Run the report draft through the client scrub choke point: the default
166
+ * scrubbers first (unless `scrub` is `false`), then the optional per-project
167
+ * hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
168
+ * the hook drops the report. A hook that throws is treated as a no-op — the
169
+ * already-scrubbed draft is kept, so a buggy hook never breaks the report path
170
+ * nor leaks unscrubbed data.
171
+ */
172
+ export function runBeforeSend(draft, options = {}) {
173
+ const current = options.scrub === false ? draft : applyDefaultScrub(draft);
174
+ const hook = options.hook;
175
+ if (!hook)
176
+ return current;
177
+ try {
178
+ const result = hook(current);
179
+ return result ?? null;
180
+ }
181
+ catch {
182
+ return current;
183
+ }
184
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The pure **shake detector** — the mobile launcher's brain (spec 0004 §B,
3
+ * ADR-0021). It consumes raw accelerometer samples (in g, gravity included, as
4
+ * `expo-sensors` reports them) and fires `onShake` when a deliberate shake is
5
+ * recognised: enough acceleration-magnitude **peaks** over a threshold, close
6
+ * enough together, debounced so one sustained swing counts once, with a
7
+ * cooldown so a single shake never double-opens the composer.
8
+ *
9
+ * Kept free of any React Native import so the recogniser is unit-tested
10
+ * deterministically; the sensor wiring lives in `adapters.ts`.
11
+ */
12
+ /** One accelerometer reading, in g per axis, stamped with an epoch-ms time. */
13
+ export interface ShakeSample {
14
+ readonly x: number;
15
+ readonly y: number;
16
+ readonly z: number;
17
+ /** Epoch milliseconds when the sample was taken. */
18
+ readonly at: number;
19
+ }
20
+ /** The tunable numbers of the recogniser — spec 0004 §B's defaults. */
21
+ export interface ShakeTuning {
22
+ /** A sample's magnitude must reach this (in g) to count as a peak. */
23
+ readonly thresholdG: number;
24
+ /** How many peaks within {@link windowMs} fire a shake. */
25
+ readonly minPeaks: number;
26
+ /** The sliding window peaks must fall inside. */
27
+ readonly windowMs: number;
28
+ /** Two peaks closer than this are one swing — debounce. */
29
+ readonly minGapMs: number;
30
+ /** After firing, ignore everything for this long. */
31
+ readonly cooldownMs: number;
32
+ }
33
+ /** The default tuning: a deliberate shake, not a bump or a phone put down hard. */
34
+ export declare const DEFAULT_SHAKE_TUNING: ShakeTuning;
35
+ /** Configuration for {@link createShakeDetector}. */
36
+ export interface ShakeDetectorOptions extends Partial<ShakeTuning> {
37
+ /** Called once per recognised shake. A throwing callback is swallowed. */
38
+ readonly onShake: () => void;
39
+ }
40
+ /** A live shake recogniser. */
41
+ export interface ShakeDetector {
42
+ /** Feed one accelerometer sample. */
43
+ sample(sample: ShakeSample): void;
44
+ /** Forget accumulated peaks and any cooldown. */
45
+ reset(): void;
46
+ }
47
+ /**
48
+ * Create a shake recogniser. The peak list is pruned to the sliding window on
49
+ * every sample, so memory stays bounded however long the app runs.
50
+ */
51
+ export declare function createShakeDetector(options: ShakeDetectorOptions): ShakeDetector;
package/dist/shake.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The pure **shake detector** — the mobile launcher's brain (spec 0004 §B,
3
+ * ADR-0021). It consumes raw accelerometer samples (in g, gravity included, as
4
+ * `expo-sensors` reports them) and fires `onShake` when a deliberate shake is
5
+ * recognised: enough acceleration-magnitude **peaks** over a threshold, close
6
+ * enough together, debounced so one sustained swing counts once, with a
7
+ * cooldown so a single shake never double-opens the composer.
8
+ *
9
+ * Kept free of any React Native import so the recogniser is unit-tested
10
+ * deterministically; the sensor wiring lives in `adapters.ts`.
11
+ */
12
+ /** The default tuning: a deliberate shake, not a bump or a phone put down hard. */
13
+ export const DEFAULT_SHAKE_TUNING = {
14
+ thresholdG: 2.0,
15
+ minPeaks: 3,
16
+ windowMs: 900,
17
+ minGapMs: 70,
18
+ cooldownMs: 2000,
19
+ };
20
+ function tuned(value, fallback) {
21
+ return typeof value === "number" && Number.isFinite(value) && value > 0
22
+ ? value
23
+ : fallback;
24
+ }
25
+ /**
26
+ * Create a shake recogniser. The peak list is pruned to the sliding window on
27
+ * every sample, so memory stays bounded however long the app runs.
28
+ */
29
+ export function createShakeDetector(options) {
30
+ const thresholdG = tuned(options.thresholdG, DEFAULT_SHAKE_TUNING.thresholdG);
31
+ const minPeaks = Math.max(1, Math.floor(tuned(options.minPeaks, DEFAULT_SHAKE_TUNING.minPeaks)));
32
+ const windowMs = tuned(options.windowMs, DEFAULT_SHAKE_TUNING.windowMs);
33
+ const minGapMs = tuned(options.minGapMs, DEFAULT_SHAKE_TUNING.minGapMs);
34
+ const cooldownMs = tuned(options.cooldownMs, DEFAULT_SHAKE_TUNING.cooldownMs);
35
+ /** Epoch-ms times of the peaks inside the current window. */
36
+ let peaks = [];
37
+ let lastPeakAt = Number.NEGATIVE_INFINITY;
38
+ let lastFiredAt = Number.NEGATIVE_INFINITY;
39
+ return {
40
+ sample(sample) {
41
+ const { x, y, z, at } = sample;
42
+ if (!Number.isFinite(x) ||
43
+ !Number.isFinite(y) ||
44
+ !Number.isFinite(z) ||
45
+ !Number.isFinite(at)) {
46
+ return;
47
+ }
48
+ // Everything inside the cooldown is ignored — including peak bookkeeping,
49
+ // so the tail of the shake that fired can't seed the next window.
50
+ if (at - lastFiredAt < cooldownMs)
51
+ return;
52
+ const magnitude = Math.sqrt(x * x + y * y + z * z);
53
+ if (magnitude < thresholdG)
54
+ return;
55
+ // Debounce: a sustained swing above the threshold is one peak, not many.
56
+ if (at - lastPeakAt < minGapMs)
57
+ return;
58
+ lastPeakAt = at;
59
+ peaks.push(at);
60
+ const cutoff = at - windowMs;
61
+ peaks = peaks.filter((t) => t >= cutoff);
62
+ if (peaks.length >= minPeaks) {
63
+ peaks = [];
64
+ lastFiredAt = at;
65
+ try {
66
+ options.onShake();
67
+ }
68
+ catch {
69
+ // The gesture must never throw into the host app.
70
+ }
71
+ }
72
+ },
73
+ reset() {
74
+ peaks = [];
75
+ lastPeakAt = Number.NEGATIVE_INFINITY;
76
+ lastFiredAt = Number.NEGATIVE_INFINITY;
77
+ },
78
+ };
79
+ }