@marktiderman/genesis-native 0.1.0 → 0.3.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/README.md +354 -10
- package/dist/block-C5-noMhB.d.ts +223 -0
- package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push.d.ts +169 -0
- package/dist/push.js +996 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/package.json +222 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/push/backend.ts +129 -0
- package/src/push/block.tsx +545 -0
- package/src/push/index.ts +102 -0
- package/src/push/modules.ts +111 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +189 -0
- package/src/push/routing.ts +27 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- package/dist/chunk-MMTPWUWI.js.map +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createDevtools(options)`: the devtools overlay, for development, preview
|
|
3
|
+
* and internal builds only. A hold with three fingers (in every build), a
|
|
4
|
+
* shake (with an accelerometer) or "Open devtools" in React Native's dev menu
|
|
5
|
+
* opens a sheet with:
|
|
6
|
+
*
|
|
7
|
+
* - the version signature, the update channel and a channel switch (the
|
|
8
|
+
* updates block's guarded `channels`: on only where it allows it);
|
|
9
|
+
* - the signed-in user's id, the blocks in effect (`useBlocks`) and the
|
|
10
|
+
* app's flags;
|
|
11
|
+
* - the recent breadcrumbs and errors (the observability block's, scrubbed);
|
|
12
|
+
* - the server's base URL;
|
|
13
|
+
* - "Copy debug info", "Crash test", "Clear cache" and "Sign out";
|
|
14
|
+
* - the `devtoolsFeedback` slot, which the support block fills with
|
|
15
|
+
* "Send feedback".
|
|
16
|
+
*
|
|
17
|
+
* Keep it out of production bundles: import this entry only behind a
|
|
18
|
+
* condition Metro can fold at build time (see the README), and pass
|
|
19
|
+
* `enabled` from a fact of the binary as a second lock. With `enabled`
|
|
20
|
+
* false the block mounts nothing and listens to nothing.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
|
|
23
|
+
import { Alert, Modal, ScrollView, Share, View, type GestureResponderEvent } from "react-native";
|
|
24
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
25
|
+
import { renderSlot } from "@marktiderman/genesis-core/slots";
|
|
26
|
+
import { NativeButton, NativeInput, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
27
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
28
|
+
import { defineBlock } from "@marktiderman/genesis-features/blocks";
|
|
29
|
+
import { useSession } from "../auth";
|
|
30
|
+
import { useBlocks, type NativeBlock, type NativeBlockRootProps } from "../blocks";
|
|
31
|
+
import { useRecentActivity, type ObservabilityBlock } from "../observability/block";
|
|
32
|
+
import { scrubString, scrubUrl } from "../observability/scrub";
|
|
33
|
+
import type { UpdatesBlock } from "../updates/block";
|
|
34
|
+
import type { ChannelSwitchResult } from "../updates/channels";
|
|
35
|
+
import { versionSignature, type VersionSignature } from "../updates/signature";
|
|
36
|
+
import { createHoldDetector, createShakeDetector, type AccelerometerLike } from "./gestures";
|
|
37
|
+
import { buildDebugInfo } from "./info";
|
|
38
|
+
import type { DevtoolsFeedbackProps } from "./slots";
|
|
39
|
+
|
|
40
|
+
/** The FeatureId of the devtools overlay. */
|
|
41
|
+
export const DEVTOOLS_BLOCK_ID = "devtools-overlay";
|
|
42
|
+
|
|
43
|
+
/** React Native's `DevSettings` (development builds only), or a stand-in. */
|
|
44
|
+
export interface DevSettingsLike {
|
|
45
|
+
addMenuItem(title: string, handler: () => void): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DevtoolsOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Must be explicitly true: `__DEV__` or a fact of the binary (a `.dev` or
|
|
51
|
+
* `.preview` bundle id). False mounts nothing.
|
|
52
|
+
*/
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
/** The updates block: the signature, the channel and the channel switch. */
|
|
55
|
+
updates?: UpdatesBlock;
|
|
56
|
+
/** Without the updates block: the signature to show (`versionSignature(…)`). */
|
|
57
|
+
signature?: VersionSignature;
|
|
58
|
+
/** The observability block: recent breadcrumbs and errors. */
|
|
59
|
+
observability?: ObservabilityBlock | null;
|
|
60
|
+
/** The server the app talks to (the Supabase URL). Shown without its query string. */
|
|
61
|
+
baseUrl?: string;
|
|
62
|
+
/** The app's switches and flags, shown by name and value. */
|
|
63
|
+
flags?: Readonly<Record<string, unknown>>;
|
|
64
|
+
/** `Accelerometer` from expo-sensors: shake to open. Without it, the hold and the dev menu still work. */
|
|
65
|
+
accelerometer?: AccelerometerLike;
|
|
66
|
+
/** `DevSettings` from react-native: "Open devtools" in the dev menu (development builds). */
|
|
67
|
+
devSettings?: DevSettingsLike;
|
|
68
|
+
/** The hold that opens the overlay, or `false` for none. Default three fingers for 800 ms. */
|
|
69
|
+
hold?: false | { fingers?: number; holdMs?: number };
|
|
70
|
+
/** Copy text (`Clipboard.setStringAsync` from expo-clipboard). Default the share sheet, which has Copy. */
|
|
71
|
+
copy?: (text: string) => unknown;
|
|
72
|
+
/** More to clear after the query cache (the persisted cache, MMKV). */
|
|
73
|
+
clearCache?: () => unknown;
|
|
74
|
+
/** Sign out (`supabase.auth.signOut()`). Without it, the action is hidden. */
|
|
75
|
+
signOut?: () => unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DevtoolsState {
|
|
79
|
+
open: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DevtoolsBlock extends NativeBlock<typeof DEVTOOLS_BLOCK_ID> {
|
|
83
|
+
readonly enabled: boolean;
|
|
84
|
+
readonly options: DevtoolsOptions;
|
|
85
|
+
open(): void;
|
|
86
|
+
/** Closes the overlay. Resolves once its sheet has finished sliding away (or at once if it isn't up). */
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
getState(): DevtoolsState;
|
|
89
|
+
subscribe(listener: () => void): () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the overlay once, at module scope. @stability Beta */
|
|
93
|
+
export function createDevtools(options: DevtoolsOptions): DevtoolsBlock {
|
|
94
|
+
const enabled = options.enabled === true;
|
|
95
|
+
let state: DevtoolsState = { open: false };
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
const set = (next: DevtoolsState) => {
|
|
98
|
+
if (next.open === state.open) return;
|
|
99
|
+
state = next;
|
|
100
|
+
listeners.forEach((l) => l());
|
|
101
|
+
};
|
|
102
|
+
const subscribe = (listener: () => void) => {
|
|
103
|
+
listeners.add(listener);
|
|
104
|
+
return () => listeners.delete(listener);
|
|
105
|
+
};
|
|
106
|
+
const getState = () => state;
|
|
107
|
+
|
|
108
|
+
// Waiting for the sheet to be gone: its dismissal is animated, and a screenshot taken before it
|
|
109
|
+
// ends catches the sheet on its way down. Resolved by the modal's `onDismiss`, or after
|
|
110
|
+
// DISMISS_FALLBACK_MS where a platform doesn't report it.
|
|
111
|
+
let sheetShown = false;
|
|
112
|
+
let dismissed: Array<() => void> = [];
|
|
113
|
+
const resolveWaiting = () => {
|
|
114
|
+
const waiting = dismissed;
|
|
115
|
+
dismissed = [];
|
|
116
|
+
waiting.forEach((resolve) => resolve());
|
|
117
|
+
};
|
|
118
|
+
const settleDismissed = () => {
|
|
119
|
+
sheetShown = false;
|
|
120
|
+
resolveWaiting();
|
|
121
|
+
};
|
|
122
|
+
const close = (): Promise<void> => {
|
|
123
|
+
set({ open: false });
|
|
124
|
+
if (!sheetShown) return Promise.resolve();
|
|
125
|
+
return new Promise<void>((resolve) => dismissed.push(resolve));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const declaration = defineBlock<typeof DEVTOOLS_BLOCK_ID, ComponentType<never>>({
|
|
129
|
+
id: DEVTOOLS_BLOCK_ID,
|
|
130
|
+
platforms: ["native"],
|
|
131
|
+
contributes: {
|
|
132
|
+
privacy: { notes: ["Development, preview and internal builds only; shows on the device what the app knows about itself and sends nothing."] },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Thrown while rendering, so it takes the path a real render error takes. The shell's
|
|
137
|
+
// "Try again" remounts the root, whose state starts without the crash.
|
|
138
|
+
function CrashTest(): null {
|
|
139
|
+
throw new Error("Devtools crash test: a render error on purpose.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let menuAdded = false;
|
|
143
|
+
function DevtoolsRoot({ slots, slotProps, children }: NativeBlockRootProps) {
|
|
144
|
+
const { open } = useSyncExternalStore(subscribe, getState, getState);
|
|
145
|
+
const [crash, setCrash] = useState(false);
|
|
146
|
+
// The sheet stays mounted after `open` turns false, so the modal animates out and reports
|
|
147
|
+
// when it's gone (`onDismiss`); then it unmounts.
|
|
148
|
+
const [mounted, setMounted] = useState(open);
|
|
149
|
+
const shown = open || mounted;
|
|
150
|
+
// Unmounted (the crash test, a shell remount): nothing will report a dismissal, so nobody waits.
|
|
151
|
+
useEffect(() => () => settleDismissed(), []);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (open) {
|
|
154
|
+
// Opened again before the last close finished: that wait is over, the sheet stays.
|
|
155
|
+
resolveWaiting();
|
|
156
|
+
setMounted(true);
|
|
157
|
+
sheetShown = true;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!mounted) return;
|
|
161
|
+
const fallback = setTimeout(() => {
|
|
162
|
+
setMounted(false);
|
|
163
|
+
settleDismissed();
|
|
164
|
+
}, DISMISS_FALLBACK_MS);
|
|
165
|
+
return () => clearTimeout(fallback);
|
|
166
|
+
}, [open, mounted]);
|
|
167
|
+
const onDismissed = () => {
|
|
168
|
+
if (getState().open) return;
|
|
169
|
+
setMounted(false);
|
|
170
|
+
settleDismissed();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// React Native has no way to remove a dev menu item: add it once per process.
|
|
175
|
+
if (!options.devSettings || menuAdded) return;
|
|
176
|
+
menuAdded = true;
|
|
177
|
+
options.devSettings.addMenuItem("Open devtools", () => set({ open: true }));
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const accelerometer = options.accelerometer;
|
|
182
|
+
if (!accelerometer) return;
|
|
183
|
+
let subscription: { remove(): void } | null = null;
|
|
184
|
+
let stopped = false;
|
|
185
|
+
const detector = createShakeDetector({ onShake: () => set({ open: true }) });
|
|
186
|
+
void (async () => {
|
|
187
|
+
try {
|
|
188
|
+
if (accelerometer.isAvailableAsync && !(await accelerometer.isAvailableAsync())) return;
|
|
189
|
+
if (stopped) return;
|
|
190
|
+
accelerometer.setUpdateInterval?.(100);
|
|
191
|
+
subscription = accelerometer.addListener((reading) => detector.sample(reading));
|
|
192
|
+
} catch {
|
|
193
|
+
// No accelerometer here (a simulator): the hold and the dev menu still open it.
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
return () => {
|
|
197
|
+
stopped = true;
|
|
198
|
+
subscription?.remove();
|
|
199
|
+
};
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const hold = useRef(options.hold === false ? null : createHoldDetector({ ...(options.hold || {}), onTrigger: () => set({ open: true }) }));
|
|
203
|
+
useEffect(() => () => hold.current?.cancel(), []);
|
|
204
|
+
const onTouches = hold.current ? (event: GestureResponderEvent) => hold.current?.touches(event.nativeEvent.touches.length) : undefined;
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<View style={{ flex: 1 }} onTouchStart={onTouches} onTouchMove={onTouches} onTouchEnd={onTouches} onTouchCancel={onTouches}>
|
|
208
|
+
{children}
|
|
209
|
+
{crash ? <CrashTest /> : null}
|
|
210
|
+
{shown ? (
|
|
211
|
+
<DevtoolsSheet
|
|
212
|
+
visible={open}
|
|
213
|
+
onDismissed={onDismissed}
|
|
214
|
+
block={block}
|
|
215
|
+
Feedback={slots?.devtoolsFeedback}
|
|
216
|
+
feedbackProps={slotProps?.devtoolsFeedback}
|
|
217
|
+
onCrash={() => {
|
|
218
|
+
set({ open: false });
|
|
219
|
+
setCrash(true);
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
) : null}
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const block: DevtoolsBlock = {
|
|
228
|
+
...declaration,
|
|
229
|
+
enabled,
|
|
230
|
+
options,
|
|
231
|
+
open: () => enabled && set({ open: true }),
|
|
232
|
+
close,
|
|
233
|
+
getState,
|
|
234
|
+
subscribe,
|
|
235
|
+
...(enabled ? { Root: DevtoolsRoot } : {}),
|
|
236
|
+
};
|
|
237
|
+
return block;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function NoFeedback(_props: DevtoolsFeedbackProps) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function useSignature(options: DevtoolsOptions): VersionSignature | null {
|
|
245
|
+
const updates = options.updates;
|
|
246
|
+
const [requested, setRequested] = useState<string | null>(null);
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!updates) return;
|
|
249
|
+
let alive = true;
|
|
250
|
+
void updates.channels.requested().then((value) => alive && setRequested(value));
|
|
251
|
+
return () => {
|
|
252
|
+
alive = false;
|
|
253
|
+
};
|
|
254
|
+
}, [updates]);
|
|
255
|
+
return useMemo(() => {
|
|
256
|
+
if (!updates) return options.signature ?? null;
|
|
257
|
+
if (!requested) return updates.signature;
|
|
258
|
+
const { application, updates: modules, platform } = updates.options;
|
|
259
|
+
return versionSignature({ application, updates: modules, platform, requestedChannel: requested });
|
|
260
|
+
}, [updates, requested, options.signature]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const RESULT_TEXT: Record<ChannelSwitchResult["kind"], string> = {
|
|
264
|
+
reloading: "Reloading…",
|
|
265
|
+
blocked: "Set; it applies on the next launch (something is mid-edit).",
|
|
266
|
+
unavailable: "Not available in this build.",
|
|
267
|
+
error: "Set, but checking or reloading failed.",
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** Longest wait for the sheet's `onDismiss` before treating it as gone. */
|
|
271
|
+
const DISMISS_FALLBACK_MS = 800;
|
|
272
|
+
|
|
273
|
+
interface SheetProps {
|
|
274
|
+
visible: boolean;
|
|
275
|
+
onDismissed: () => void;
|
|
276
|
+
block: DevtoolsBlock;
|
|
277
|
+
Feedback: ComponentType<DevtoolsFeedbackProps> | null | undefined;
|
|
278
|
+
feedbackProps: Partial<DevtoolsFeedbackProps> | undefined;
|
|
279
|
+
onCrash: () => void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Layout by `style`, not `className`: this package is compiled before the app's NativeWind
|
|
283
|
+
// transform runs, so a class on React Native's own View would do nothing.
|
|
284
|
+
function Row({ label, value, testID }: { label: string; value: string; testID?: string }) {
|
|
285
|
+
return (
|
|
286
|
+
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12, paddingVertical: 4 }} testID={testID}>
|
|
287
|
+
<NativeText preset="label">{label}</NativeText>
|
|
288
|
+
<NativeText preset="body-sm" className="text-right text-muted-foreground" style={{ flex: 1 }} selectable>
|
|
289
|
+
{value}
|
|
290
|
+
</NativeText>
|
|
291
|
+
</View>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
296
|
+
const roles = useThemeRoles();
|
|
297
|
+
return (
|
|
298
|
+
<View style={{ gap: 4, borderTopWidth: 1, borderTopColor: roles.separator, paddingTop: 12 }}>
|
|
299
|
+
<NativeText preset="label" accessibilityRole="header">
|
|
300
|
+
{title}
|
|
301
|
+
</NativeText>
|
|
302
|
+
{children}
|
|
303
|
+
</View>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function DevtoolsSheet({ visible, onDismissed, block, Feedback, feedbackProps, onCrash }: SheetProps) {
|
|
308
|
+
const { options } = block;
|
|
309
|
+
const roles = useThemeRoles();
|
|
310
|
+
const { userId } = useSession();
|
|
311
|
+
const blocks = useBlocks();
|
|
312
|
+
const recent = useRecentActivity(options.observability ?? null);
|
|
313
|
+
const signature = useSignature(options);
|
|
314
|
+
const queryClient = useQueryClient();
|
|
315
|
+
const [channel, setChannel] = useState("");
|
|
316
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
317
|
+
const channels = options.updates?.channels;
|
|
318
|
+
const close = () => block.close();
|
|
319
|
+
|
|
320
|
+
const info = () =>
|
|
321
|
+
buildDebugInfo({ signature, userId, blocks: [...blocks.ids], flags: options.flags, baseUrl: options.baseUrl ?? null, recent });
|
|
322
|
+
|
|
323
|
+
const run = (label: string, action: () => unknown) => {
|
|
324
|
+
Promise.resolve()
|
|
325
|
+
.then(action)
|
|
326
|
+
.then(
|
|
327
|
+
() => setStatus(`${label}: done`),
|
|
328
|
+
(error: unknown) => setStatus(`${label}: ${error instanceof Error ? error.message : "failed"}`),
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const copy = () => run("Copy debug info", () => (options.copy ? options.copy(info()) : Share.share({ message: info() })));
|
|
333
|
+
const clearCache = () =>
|
|
334
|
+
run("Clear cache", async () => {
|
|
335
|
+
queryClient.clear();
|
|
336
|
+
await options.clearCache?.();
|
|
337
|
+
});
|
|
338
|
+
const confirm = (title: string, body: string, action: string, onConfirm: () => void) =>
|
|
339
|
+
Alert.alert(title, body, [
|
|
340
|
+
{ text: "Cancel", style: "cancel" },
|
|
341
|
+
{ text: action, style: "destructive", onPress: onConfirm },
|
|
342
|
+
]);
|
|
343
|
+
const switchChannel = () => {
|
|
344
|
+
if (!channels) return;
|
|
345
|
+
run("Switch channel", async () => {
|
|
346
|
+
const result = await channels.switchTo(channel);
|
|
347
|
+
setStatus(RESULT_TEXT[result.kind]);
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
const resetChannel = () => {
|
|
351
|
+
if (!channels) return;
|
|
352
|
+
run("Build channel", async () => {
|
|
353
|
+
const result = await channels.reset();
|
|
354
|
+
setStatus(RESULT_TEXT[result.kind]);
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const flags = Object.entries(options.flags ?? {});
|
|
359
|
+
return (
|
|
360
|
+
// React Native's own modal, as a page sheet on iOS: it needs no provider above it (a bottom-sheet
|
|
361
|
+
// library's modal does), so the overlay opens whatever the app has mounted.
|
|
362
|
+
<Modal
|
|
363
|
+
visible={visible}
|
|
364
|
+
animationType="slide"
|
|
365
|
+
presentationStyle="pageSheet"
|
|
366
|
+
onRequestClose={() => void close()}
|
|
367
|
+
onDismiss={onDismissed}
|
|
368
|
+
testID="genesis-devtools"
|
|
369
|
+
>
|
|
370
|
+
<ScrollView style={{ flex: 1, backgroundColor: roles.background }} contentContainerStyle={{ gap: 12, padding: 16, paddingBottom: 48 }}>
|
|
371
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
372
|
+
<NativeText preset="h3" accessibilityRole="header">
|
|
373
|
+
Devtools
|
|
374
|
+
</NativeText>
|
|
375
|
+
<NativeButton variant="ghost" size="sm" onPress={() => void close()} testID="genesis-devtools-close">
|
|
376
|
+
Close
|
|
377
|
+
</NativeButton>
|
|
378
|
+
</View>
|
|
379
|
+
{status ? (
|
|
380
|
+
<NativeText preset="body-sm" testID="genesis-devtools-status">
|
|
381
|
+
{status}
|
|
382
|
+
</NativeText>
|
|
383
|
+
) : null}
|
|
384
|
+
|
|
385
|
+
<Section title="Build">
|
|
386
|
+
<NativeText preset="body-sm" selectable testID="genesis-devtools-signature">
|
|
387
|
+
{signature?.text ?? "Version unknown (no updates block)"}
|
|
388
|
+
</NativeText>
|
|
389
|
+
</Section>
|
|
390
|
+
|
|
391
|
+
{channels ? (
|
|
392
|
+
<Section title="Update channel">
|
|
393
|
+
<Row label="Channel" value={signature?.requestedChannel ?? signature?.channel ?? "none"} testID="genesis-devtools-channel" />
|
|
394
|
+
{channels.enabled ? (
|
|
395
|
+
<>
|
|
396
|
+
<NativeInput
|
|
397
|
+
value={channel}
|
|
398
|
+
onChangeText={setChannel}
|
|
399
|
+
placeholder="pr-42"
|
|
400
|
+
autoCapitalize="none"
|
|
401
|
+
autoCorrect={false}
|
|
402
|
+
accessibilityLabel="Channel to switch to"
|
|
403
|
+
testID="genesis-devtools-channel-input"
|
|
404
|
+
/>
|
|
405
|
+
<NativeButton size="sm" onPress={switchChannel} disabled={!channel.trim()} testID="genesis-devtools-channel-switch">
|
|
406
|
+
Switch and reload
|
|
407
|
+
</NativeButton>
|
|
408
|
+
</>
|
|
409
|
+
) : (
|
|
410
|
+
<NativeText preset="body-sm" className="text-muted-foreground">
|
|
411
|
+
Switching is off in this build.
|
|
412
|
+
</NativeText>
|
|
413
|
+
)}
|
|
414
|
+
<NativeButton size="sm" variant="ghost" onPress={resetChannel} testID="genesis-devtools-channel-reset">
|
|
415
|
+
Back to the build's channel
|
|
416
|
+
</NativeButton>
|
|
417
|
+
</Section>
|
|
418
|
+
) : null}
|
|
419
|
+
|
|
420
|
+
<Section title="Session">
|
|
421
|
+
<Row label="User id" value={userId ?? "signed out"} testID="genesis-devtools-user" />
|
|
422
|
+
<Row label="Server" value={options.baseUrl ? scrubUrl(options.baseUrl) : "unknown"} testID="genesis-devtools-server" />
|
|
423
|
+
</Section>
|
|
424
|
+
|
|
425
|
+
<Section title="Blocks and flags">
|
|
426
|
+
<Row label="Blocks" value={blocks.ids.size > 0 ? [...blocks.ids].join(", ") : "none"} testID="genesis-devtools-blocks" />
|
|
427
|
+
{flags.map(([name, value]) => (
|
|
428
|
+
<Row key={name} label={name} value={typeof value === "string" ? scrubString(value) : String(value)} testID={`genesis-devtools-flag-${name}`} />
|
|
429
|
+
))}
|
|
430
|
+
</Section>
|
|
431
|
+
|
|
432
|
+
<Section title={`Errors (${recent.errors.length})`}>
|
|
433
|
+
{recent.errors.length === 0 ? <NativeText preset="body-sm">None</NativeText> : null}
|
|
434
|
+
{recent.errors
|
|
435
|
+
.slice(-10)
|
|
436
|
+
.reverse()
|
|
437
|
+
.map((e) => (
|
|
438
|
+
<NativeText key={`${e.at}-${e.message}`} preset="body-sm" testID="genesis-devtools-error">
|
|
439
|
+
{`${e.fatal ? "fatal " : ""}${e.source} · ${e.name}: ${e.message}`}
|
|
440
|
+
</NativeText>
|
|
441
|
+
))}
|
|
442
|
+
</Section>
|
|
443
|
+
|
|
444
|
+
<Section title={`Breadcrumbs (${recent.breadcrumbs.length})`}>
|
|
445
|
+
{recent.breadcrumbs.length === 0 ? <NativeText preset="body-sm">None</NativeText> : null}
|
|
446
|
+
{recent.breadcrumbs
|
|
447
|
+
.slice(-15)
|
|
448
|
+
.reverse()
|
|
449
|
+
.map((c, i) => (
|
|
450
|
+
<NativeText key={`${c.timestamp ?? 0}-${i}`} preset="body-sm" testID="genesis-devtools-breadcrumb">
|
|
451
|
+
{`${c.category} · ${c.message ?? ""}`}
|
|
452
|
+
</NativeText>
|
|
453
|
+
))}
|
|
454
|
+
</Section>
|
|
455
|
+
|
|
456
|
+
<Section title="Actions">
|
|
457
|
+
<View style={{ gap: 8 }}>
|
|
458
|
+
<NativeButton size="sm" variant="outline" onPress={copy} testID="genesis-devtools-copy">
|
|
459
|
+
Copy debug info
|
|
460
|
+
</NativeButton>
|
|
461
|
+
{renderSlot(NoFeedback, Feedback, { debugInfo: info(), close }, feedbackProps)}
|
|
462
|
+
<NativeButton size="sm" variant="outline" onPress={clearCache} testID="genesis-devtools-clear-cache">
|
|
463
|
+
Clear cache
|
|
464
|
+
</NativeButton>
|
|
465
|
+
<NativeButton
|
|
466
|
+
size="sm"
|
|
467
|
+
variant="outline"
|
|
468
|
+
onPress={() => confirm("Crash test", "This throws a render error: the fatal screen shows and the error is reported.", "Crash", onCrash)}
|
|
469
|
+
testID="genesis-devtools-crash"
|
|
470
|
+
>
|
|
471
|
+
Crash test
|
|
472
|
+
</NativeButton>
|
|
473
|
+
{options.signOut ? (
|
|
474
|
+
<NativeButton
|
|
475
|
+
size="sm"
|
|
476
|
+
variant="destructive"
|
|
477
|
+
onPress={() => confirm("Sign out", "Sign out of this device?", "Sign out", () => run("Sign out", options.signOut as () => unknown))}
|
|
478
|
+
testID="genesis-devtools-sign-out"
|
|
479
|
+
>
|
|
480
|
+
Sign out
|
|
481
|
+
</NativeButton>
|
|
482
|
+
) : null}
|
|
483
|
+
</View>
|
|
484
|
+
</Section>
|
|
485
|
+
</ScrollView>
|
|
486
|
+
</Modal>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay's two ways in, as plain state machines (the root feeds them
|
|
3
|
+
* touches and accelerometer samples; tests feed them numbers).
|
|
4
|
+
*
|
|
5
|
+
* - A hold with three fingers (`fingers`) for `holdMs`. Works in every build
|
|
6
|
+
* with no native module.
|
|
7
|
+
* - A shake, from an accelerometer (`Accelerometer` from expo-sensors, in g):
|
|
8
|
+
* `required` samples above `threshold` g (gravity removed) within
|
|
9
|
+
* `windowMs`, then nothing for `cooldownMs`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface HoldDetectorOptions {
|
|
13
|
+
/** Default 3. */
|
|
14
|
+
fingers?: number;
|
|
15
|
+
/** Default 800 ms. */
|
|
16
|
+
holdMs?: number;
|
|
17
|
+
onTrigger: () => void;
|
|
18
|
+
setTimer?: (run: () => void, ms: number) => unknown;
|
|
19
|
+
clearTimer?: (handle: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HoldDetector {
|
|
23
|
+
/** The number of fingers down now (`event.nativeEvent.touches.length`). */
|
|
24
|
+
touches(count: number): void;
|
|
25
|
+
cancel(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createHoldDetector(options: HoldDetectorOptions): HoldDetector {
|
|
29
|
+
const fingers = Math.max(2, options.fingers ?? 3);
|
|
30
|
+
const holdMs = options.holdMs ?? 800;
|
|
31
|
+
const setTimer = options.setTimer ?? ((run, ms) => setTimeout(run, ms));
|
|
32
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
33
|
+
let timer: unknown = null;
|
|
34
|
+
// One trigger per hold: lifting a finger re-arms it.
|
|
35
|
+
let fired = false;
|
|
36
|
+
const cancel = () => {
|
|
37
|
+
if (timer !== null) clearTimer(timer);
|
|
38
|
+
timer = null;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
touches(count) {
|
|
42
|
+
if (count < fingers) {
|
|
43
|
+
cancel();
|
|
44
|
+
fired = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (timer !== null || fired) return;
|
|
48
|
+
timer = setTimer(() => {
|
|
49
|
+
timer = null;
|
|
50
|
+
fired = true;
|
|
51
|
+
options.onTrigger();
|
|
52
|
+
}, holdMs);
|
|
53
|
+
},
|
|
54
|
+
cancel() {
|
|
55
|
+
cancel();
|
|
56
|
+
fired = false;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ShakeDetectorOptions {
|
|
62
|
+
/** g above gravity a sample must reach. Default 1.5. */
|
|
63
|
+
threshold?: number;
|
|
64
|
+
/** Samples over the threshold needed. Default 3. */
|
|
65
|
+
required?: number;
|
|
66
|
+
/** Default 800 ms. */
|
|
67
|
+
windowMs?: number;
|
|
68
|
+
/** Default 2000 ms. */
|
|
69
|
+
cooldownMs?: number;
|
|
70
|
+
onShake: () => void;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ShakeDetector {
|
|
75
|
+
sample(reading: { x: number; y: number; z: number }): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createShakeDetector(options: ShakeDetectorOptions): ShakeDetector {
|
|
79
|
+
const threshold = options.threshold ?? 1.5;
|
|
80
|
+
const required = options.required ?? 3;
|
|
81
|
+
const windowMs = options.windowMs ?? 800;
|
|
82
|
+
const cooldownMs = options.cooldownMs ?? 2000;
|
|
83
|
+
const now = options.now ?? Date.now;
|
|
84
|
+
let hits: number[] = [];
|
|
85
|
+
let lastFired = -Infinity;
|
|
86
|
+
return {
|
|
87
|
+
sample({ x, y, z }) {
|
|
88
|
+
const at = now();
|
|
89
|
+
if (at - lastFired < cooldownMs) return;
|
|
90
|
+
if (Math.sqrt(x * x + y * y + z * z) - 1 <= threshold) return;
|
|
91
|
+
hits = hits.filter((t) => at - t <= windowMs);
|
|
92
|
+
hits.push(at);
|
|
93
|
+
if (hits.length >= required) {
|
|
94
|
+
hits = [];
|
|
95
|
+
lastFired = at;
|
|
96
|
+
options.onShake();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `Accelerometer` from expo-sensors, or a stand-in. */
|
|
103
|
+
export interface AccelerometerLike {
|
|
104
|
+
isAvailableAsync?(): Promise<boolean>;
|
|
105
|
+
setUpdateInterval?(ms: number): void;
|
|
106
|
+
addListener(listener: (reading: { x: number; y: number; z: number }) => void): { remove(): void };
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-native/devtools`: the devtools overlay for
|
|
3
|
+
* development, preview and internal builds. Import it only behind a
|
|
4
|
+
* condition Metro folds at build time, so production bundles leave it out:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export const devtools =
|
|
8
|
+
* __DEV__ || process.env.EXPO_PUBLIC_APP_VARIANT === "preview"
|
|
9
|
+
* ? require("./devtools-overlay").createAppDevtools()
|
|
10
|
+
* : undefined;
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Rung 1: `<GenesisNativeApp blocks={[devtools]}>`. Rung 2: the
|
|
14
|
+
* `devtoolsFeedback` slot. Rung 3: `createHoldDetector`,
|
|
15
|
+
* `createShakeDetector`, `buildDebugInfo`.
|
|
16
|
+
*
|
|
17
|
+
* @stability Beta
|
|
18
|
+
*/
|
|
19
|
+
export { createDevtools, DEVTOOLS_BLOCK_ID, type DevSettingsLike, type DevtoolsBlock, type DevtoolsOptions, type DevtoolsState } from "./block";
|
|
20
|
+
export { createHoldDetector, createShakeDetector, type AccelerometerLike, type HoldDetector, type HoldDetectorOptions, type ShakeDetector, type ShakeDetectorOptions } from "./gestures";
|
|
21
|
+
export { buildDebugInfo, type DebugInfoInput } from "./info";
|
|
22
|
+
export type { DevtoolsFeedbackProps, DevtoolsSlots } from "./slots";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay's report, as text for "Copy debug info" and the feedback slot.
|
|
3
|
+
* Built from what the overlay shows, already scrubbed where it came from the
|
|
4
|
+
* observability block; the user is an id, never an email.
|
|
5
|
+
*/
|
|
6
|
+
import type { RecentActivity } from "../observability/block";
|
|
7
|
+
import { scrubString } from "../observability/scrub";
|
|
8
|
+
import type { VersionSignature } from "../updates/signature";
|
|
9
|
+
|
|
10
|
+
export interface DebugInfoInput {
|
|
11
|
+
signature?: VersionSignature | null;
|
|
12
|
+
userId?: string | null;
|
|
13
|
+
blocks?: readonly string[];
|
|
14
|
+
flags?: Readonly<Record<string, unknown>>;
|
|
15
|
+
baseUrl?: string | null;
|
|
16
|
+
recent?: RecentActivity;
|
|
17
|
+
/** Default now. */
|
|
18
|
+
at?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const UNKNOWN = "unknown";
|
|
22
|
+
|
|
23
|
+
function flagValue(value: unknown): string {
|
|
24
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
25
|
+
if (typeof value === "string") return scrubString(value);
|
|
26
|
+
return value === null || value === undefined ? "unset" : "(object)";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Several lines a person can paste into a bug report. Unknown values are written `unknown`, never left out. */
|
|
30
|
+
export function buildDebugInfo(input: DebugInfoInput): string {
|
|
31
|
+
const lines: string[] = [`Debug info ${new Date(input.at ?? Date.now()).toISOString()}`];
|
|
32
|
+
lines.push(...(input.signature ? input.signature.text.split("\n") : ["Version unknown (no updates block)"]));
|
|
33
|
+
lines.push(`User ${input.userId ?? "signed out"}`);
|
|
34
|
+
lines.push(`Server ${input.baseUrl ? scrubString(input.baseUrl.replace(/[?#].*$/, "")) : UNKNOWN}`);
|
|
35
|
+
lines.push(`Blocks ${input.blocks && input.blocks.length > 0 ? input.blocks.join(", ") : "none"}`);
|
|
36
|
+
const flags = Object.entries(input.flags ?? {});
|
|
37
|
+
lines.push(`Flags ${flags.length > 0 ? flags.map(([k, v]) => `${k}=${flagValue(v)}`).join(", ") : "none"}`);
|
|
38
|
+
const errors = input.recent?.errors ?? [];
|
|
39
|
+
lines.push(`Errors (${errors.length})`);
|
|
40
|
+
for (const e of errors.slice(-10)) lines.push(` ${new Date(e.at).toISOString()} ${e.fatal ? "fatal " : ""}${e.source} ${e.name}: ${e.message}`);
|
|
41
|
+
const crumbs = input.recent?.breadcrumbs ?? [];
|
|
42
|
+
lines.push(`Breadcrumbs (${crumbs.length})`);
|
|
43
|
+
for (const c of crumbs.slice(-20)) lines.push(` ${c.timestamp ? new Date(c.timestamp).toISOString() : UNKNOWN} ${c.category} ${c.message ?? ""}`.trimEnd());
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devtools overlay's slot on `GenesisNativeApp`. Types only, so the
|
|
3
|
+
* shell can name it without bundling the overlay.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** What the `devtoolsFeedback` slot is given: the support block's "send feedback" fills it. */
|
|
7
|
+
export interface DevtoolsFeedbackProps {
|
|
8
|
+
/** The overlay's debug report (version signature, user id, blocks, recent activity). */
|
|
9
|
+
debugInfo: string;
|
|
10
|
+
/**
|
|
11
|
+
* Closes the overlay (before a screenshot, say). Resolves once the sheet has finished sliding
|
|
12
|
+
* away, so a capture after it shows the app, not the closing sheet.
|
|
13
|
+
*/
|
|
14
|
+
close: () => Promise<void> | void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A type, not an interface, so it fits the slots contract's Record constraint.
|
|
18
|
+
export type DevtoolsSlots = {
|
|
19
|
+
/** A "send feedback" action in the devtools overlay. Default nothing; the support block fills it. */
|
|
20
|
+
devtoolsFeedback: DevtoolsFeedbackProps;
|
|
21
|
+
};
|