@tapthrough/expo 0.0.0-stage → 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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,171 @@
1
+ import Constants from 'expo-constants'
2
+ import * as Linking from 'expo-linking'
3
+ import { type Href, router, useRootNavigationState } from 'expo-router'
4
+ import { type ReactNode, useEffect } from 'react'
5
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
6
+ import { setFlagOverrides } from './core/flags.js'
7
+ import type { AuthAdapter, ResetTarget } from './core/launch.js'
8
+ import { ReportButton } from './Feedback.js'
9
+ import { useLaunchState } from './hooks.js'
10
+ import { controller, pluginConfig } from './runtime.js'
11
+
12
+ export interface TapthroughProps {
13
+ children: ReactNode
14
+ /**
15
+ * How to log in with the seeded session, e.g. `supabaseAuth(supabase)`.
16
+ * Leave it out if the app has no login.
17
+ */
18
+ auth?: AuthAdapter
19
+ /** Local state to clear before logging in: query caches, persisted keys, stores. */
20
+ reset?: ResetTarget[]
21
+ /** Overrides the config plugin's `seedHookUrl`. */
22
+ seedHookUrl?: string
23
+ /** Overrides the config plugin's `enabled`. Off unless one of them turns it on. */
24
+ enabled?: boolean
25
+ /** Shows the Report button on PR previews. Defaults to true. */
26
+ feedback?: boolean
27
+ /** Overrides the config plugin's `apiUrl`, where reports are sent. */
28
+ apiUrl?: string
29
+ }
30
+
31
+ /** For apps without login: the seeded session is ignored. */
32
+ const noAuth: AuthAdapter = { signIn: async () => {} }
33
+
34
+ /**
35
+ * Wrap your root layout's content with it. When the app opens from a
36
+ * Tapthrough link, it seeds the test user, logs in, applies flags and opens
37
+ * the scenario's screen. Otherwise it renders `children` and does nothing.
38
+ */
39
+ export function Tapthrough({
40
+ children,
41
+ auth = noAuth,
42
+ reset,
43
+ seedHookUrl,
44
+ enabled,
45
+ feedback = true,
46
+ apiUrl,
47
+ }: TapthroughProps) {
48
+ const config = pluginConfig()
49
+ const isEnabled = enabled ?? config.enabled
50
+ const hookUrl = seedHookUrl ?? config.seedHookUrl
51
+ const navigationReady = Boolean(useRootNavigationState()?.key)
52
+
53
+ useEffect(() => {
54
+ if (!isEnabled) return
55
+ if (!hookUrl) {
56
+ console.warn('[tapthrough] Enabled without a seedHookUrl; launch links will be ignored.')
57
+ return
58
+ }
59
+ const config = Constants.expoConfig
60
+ const audiences = [config?.ios?.bundleIdentifier, config?.android?.package].filter(
61
+ (id): id is string => typeof id === 'string',
62
+ )
63
+ controller.configure({
64
+ seedHookUrl: hookUrl,
65
+ expectedAudiences: audiences,
66
+ auth,
67
+ reset: reset ?? [],
68
+ applyFlags: setFlagOverrides,
69
+ })
70
+ return () => controller.configure(undefined)
71
+ }, [isEnabled, hookUrl, auth, reset])
72
+
73
+ useEffect(() => {
74
+ if (!navigationReady) return
75
+ // Back to the root of the stack, then push: the reviewer lands on the scenario's
76
+ // screen with a back button, instead of on a stack that holds only that screen.
77
+ controller.setNavigator((path) => {
78
+ if (router.canDismiss()) router.dismissAll()
79
+ router.push(path as Href)
80
+ })
81
+ return () => controller.setNavigator(undefined)
82
+ }, [navigationReady])
83
+
84
+ // Fallback for apps without `+native-intent`; tokens are de-duplicated.
85
+ useEffect(() => {
86
+ if (!isEnabled) return
87
+ let active = true
88
+ void Linking.getInitialURL().then((url) => {
89
+ if (active) controller.offer(url)
90
+ })
91
+ const subscription = Linking.addEventListener('url', ({ url }) => controller.offer(url))
92
+ return () => {
93
+ active = false
94
+ subscription.remove()
95
+ }
96
+ }, [isEnabled])
97
+
98
+ return (
99
+ <>
100
+ {children}
101
+ {(isEnabled || config.staff) && feedback ? (
102
+ <ReportButton
103
+ apiUrl={apiUrl ?? config.apiUrl}
104
+ canSetUp={Boolean(hookUrl)}
105
+ appKey={config.staff ? config.appKey : undefined}
106
+ />
107
+ ) : null}
108
+ {isEnabled ? <LaunchOverlay /> : null}
109
+ </>
110
+ )
111
+ }
112
+
113
+ function LaunchOverlay() {
114
+ const state = useLaunchState()
115
+ // The Review popover shows its own progress for setups it started.
116
+ if (state.status !== 'idle' && state.source === 'app') return null
117
+ if (state.status === 'launching') {
118
+ return (
119
+ <View style={styles.backdrop} pointerEvents="auto">
120
+ <ActivityIndicator size="large" color="#ffffff" />
121
+ <Text style={styles.backdropText}>Setting up your review…</Text>
122
+ </View>
123
+ )
124
+ }
125
+ if (state.status === 'error') {
126
+ return (
127
+ <View style={styles.card} accessibilityRole="alert">
128
+ <Text style={styles.title}>Couldn't open this preview</Text>
129
+ <Text style={styles.message}>{state.error.message}</Text>
130
+ <Pressable
131
+ onPress={controller.dismiss}
132
+ style={styles.button}
133
+ accessibilityRole="button"
134
+ hitSlop={8}
135
+ >
136
+ <Text style={styles.buttonText}>Dismiss</Text>
137
+ </Pressable>
138
+ </View>
139
+ )
140
+ }
141
+ return null
142
+ }
143
+
144
+ const styles = StyleSheet.create({
145
+ backdrop: {
146
+ position: 'absolute',
147
+ top: 0,
148
+ right: 0,
149
+ bottom: 0,
150
+ left: 0,
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ gap: 16,
154
+ backgroundColor: 'rgba(17, 17, 17, 0.85)',
155
+ },
156
+ backdropText: { color: '#ffffff', fontSize: 16 },
157
+ card: {
158
+ position: 'absolute',
159
+ left: 16,
160
+ right: 16,
161
+ bottom: 48,
162
+ padding: 16,
163
+ gap: 8,
164
+ borderRadius: 12,
165
+ backgroundColor: '#1f1f1f',
166
+ },
167
+ title: { color: '#ffffff', fontSize: 16, fontWeight: '600' },
168
+ message: { color: '#d4d4d4', fontSize: 14, lineHeight: 20 },
169
+ button: { alignSelf: 'flex-end', paddingVertical: 8, paddingHorizontal: 12 },
170
+ buttonText: { color: '#8ab4ff', fontSize: 15, fontWeight: '600' },
171
+ })
@@ -0,0 +1,116 @@
1
+ /** A box drawn on the screenshot, in fractions of its width and height (what the server expects). */
2
+ export interface AnnotationBox {
3
+ kind: 'highlight' | 'blur'
4
+ x: number
5
+ y: number
6
+ w: number
7
+ h: number
8
+ }
9
+
10
+ /** Boxes smaller than this (in fractions) are treated as accidental taps. */
11
+ const MIN_SIZE = 0.03
12
+ export const MAX_ANNOTATIONS = 10
13
+
14
+ const clamp = (value: number) => Math.min(1, Math.max(0, value))
15
+ const round = (value: number) => Math.round(value * 1000) / 1000
16
+
17
+ /**
18
+ * The box between where a drag started and where it is now, in the drawing
19
+ * area's coordinates, normalized to its size. Works in any drag direction and
20
+ * clamps to the image. Returns null while the drag is too small to be a box.
21
+ */
22
+ export function boxFromDrag(
23
+ kind: AnnotationBox['kind'],
24
+ start: { x: number; y: number },
25
+ current: { x: number; y: number },
26
+ size: { width: number; height: number },
27
+ ): AnnotationBox | null {
28
+ if (size.width <= 0 || size.height <= 0) return null
29
+ const x1 = clamp(start.x / size.width)
30
+ const y1 = clamp(start.y / size.height)
31
+ const x2 = clamp(current.x / size.width)
32
+ const y2 = clamp(current.y / size.height)
33
+ const w = Math.abs(x2 - x1)
34
+ const h = Math.abs(y2 - y1)
35
+ if (w < MIN_SIZE || h < MIN_SIZE) return null
36
+ return { kind, x: round(Math.min(x1, x2)), y: round(Math.min(y1, y2)), w: round(w), h: round(h) }
37
+ }
38
+
39
+ /** A freehand line drawn on the screenshot: points in fractions of its width and height. */
40
+ export interface AnnotationStroke {
41
+ kind: 'draw'
42
+ points: [number, number][]
43
+ }
44
+
45
+ export type Annotation = AnnotationBox | AnnotationStroke
46
+
47
+ /** The most points a stroke keeps; longer ones are thinned evenly. */
48
+ export const MAX_STROKE_POINTS = 120
49
+ /** Points closer than this to the last one (in fractions) add nothing visible. */
50
+ const MIN_STEP = 0.004
51
+
52
+ /**
53
+ * Adds a touch position, in the drawing area's coordinates, to a stroke.
54
+ * Returns the same array when the point is too close to the last one, so a
55
+ * slow finger doesn't pile up points.
56
+ */
57
+ export function addStrokePoint(
58
+ points: [number, number][],
59
+ at: { x: number; y: number },
60
+ size: { width: number; height: number },
61
+ ): [number, number][] {
62
+ if (size.width <= 0 || size.height <= 0) return points
63
+ const next: [number, number] = [round(clamp(at.x / size.width)), round(clamp(at.y / size.height))]
64
+ const last = points.at(-1)
65
+ if (last && Math.hypot(next[0] - last[0], next[1] - last[1]) < MIN_STEP) return points
66
+ return [...points, next]
67
+ }
68
+
69
+ /** Keeps the first and last points and an even spread between, up to `max`. */
70
+ export function thinStroke(
71
+ points: [number, number][],
72
+ max = MAX_STROKE_POINTS,
73
+ ): [number, number][] {
74
+ if (points.length <= max) return points
75
+ const step = (points.length - 1) / (max - 1)
76
+ return Array.from({ length: max }, (_, i) => points[Math.round(i * step)] as [number, number])
77
+ }
78
+
79
+ /** A finished stroke, or null when it was only a tap. */
80
+ export function finishStroke(points: [number, number][]): AnnotationStroke | null {
81
+ return points.length >= 2 ? { kind: 'draw', points: thinStroke(points) } : null
82
+ }
83
+
84
+ export interface Segment {
85
+ left: number
86
+ top: number
87
+ length: number
88
+ angle: number
89
+ }
90
+
91
+ /**
92
+ * A stroke as straight segments in pixels, each centered on its midpoint and
93
+ * rotated into place, for drawing with plain views.
94
+ */
95
+ export function strokeSegments(
96
+ points: [number, number][],
97
+ size: { width: number; height: number },
98
+ ): Segment[] {
99
+ const segments: Segment[] = []
100
+ for (let i = 1; i < points.length; i++) {
101
+ const [ax, ay] = points[i - 1] as [number, number]
102
+ const [bx, by] = points[i] as [number, number]
103
+ const x1 = ax * size.width
104
+ const y1 = ay * size.height
105
+ const x2 = bx * size.width
106
+ const y2 = by * size.height
107
+ const length = Math.hypot(x2 - x1, y2 - y1)
108
+ segments.push({
109
+ left: (x1 + x2) / 2 - length / 2,
110
+ top: (y1 + y2) / 2,
111
+ length,
112
+ angle: Math.atan2(y2 - y1, x2 - x1),
113
+ })
114
+ }
115
+ return segments
116
+ }
@@ -0,0 +1,45 @@
1
+ export interface Point {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export interface Bounds {
7
+ width: number
8
+ height: number
9
+ /** Space kept clear at the top and bottom, for the status bar and home indicator. */
10
+ insetTop: number
11
+ insetBottom: number
12
+ }
13
+
14
+ export const BUBBLE_SIZE = 48
15
+ export const EDGE_MARGIN = 8
16
+ /** Movement under this many points counts as a tap, not a drag. */
17
+ export const TAP_SLOP = 6
18
+
19
+ function clampY(y: number, bounds: Bounds, size: number) {
20
+ const min = bounds.insetTop
21
+ const max = bounds.height - bounds.insetBottom - size
22
+ return Math.min(Math.max(y, min), Math.max(min, max))
23
+ }
24
+
25
+ /** Where the bubble rests after a drag: the nearer side edge, kept on screen. */
26
+ export function snapToEdge(point: Point, bounds: Bounds, size = BUBBLE_SIZE): Point {
27
+ const left = point.x + size / 2 < bounds.width / 2
28
+ return {
29
+ x: left ? EDGE_MARGIN : bounds.width - size - EDGE_MARGIN,
30
+ y: clampY(point.y, bounds, size),
31
+ }
32
+ }
33
+
34
+ /** Starts on the right edge, a little above the bottom, clear of most tab bars. */
35
+ export function defaultPosition(bounds: Bounds, size = BUBBLE_SIZE): Point {
36
+ return snapToEdge(
37
+ { x: bounds.width, y: bounds.height - bounds.insetBottom - size - 96 },
38
+ bounds,
39
+ size,
40
+ )
41
+ }
42
+
43
+ export function isTap(dx: number, dy: number) {
44
+ return Math.abs(dx) < TAP_SLOP && Math.abs(dy) < TAP_SLOP
45
+ }
@@ -0,0 +1,119 @@
1
+ import { scenarioIdFromToken } from './feedback.js'
2
+ import { type LaunchDeps, type LaunchError, type LaunchStage, runLaunch } from './launch.js'
3
+ import { parseLaunchUrl } from './url.js'
4
+
5
+ /**
6
+ * Where a launch came from: a link opened from outside the app, or the
7
+ * Review popover's Set Up button. The popover shows its own progress for
8
+ * launches it started, so the full-screen overlay only covers links.
9
+ */
10
+ export type LaunchSource = 'link' | 'app'
11
+
12
+ export type LaunchState =
13
+ | { status: 'idle' }
14
+ | { status: 'launching'; source: LaunchSource; stage?: LaunchStage }
15
+ | { status: 'done'; source: LaunchSource; path: string }
16
+ | { status: 'error'; source: LaunchSource; error: LaunchError }
17
+
18
+ export type ControllerDeps = Omit<LaunchDeps, 'navigate'>
19
+ type Navigate = LaunchDeps['navigate']
20
+
21
+ /** A value that may not exist yet; `get()` waits for it. */
22
+ function slot<T>() {
23
+ let value: T | undefined
24
+ let waiters: ((value: T) => void)[] = []
25
+ return {
26
+ set(next: T | undefined) {
27
+ value = next
28
+ if (next === undefined) return
29
+ for (const resolve of waiters) resolve(next)
30
+ waiters = []
31
+ },
32
+ get(): Promise<T> {
33
+ return value !== undefined
34
+ ? Promise.resolve(value)
35
+ : new Promise((resolve) => waiters.push(resolve))
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Turns incoming URLs into launches. Each token runs once and launches run one
42
+ * at a time. A URL can arrive before the app is ready (Expo Router hands over
43
+ * the initial URL before the root layout mounts), so each launch waits for
44
+ * `configure()` and navigation waits for `setNavigator()`.
45
+ */
46
+ export function createLaunchController() {
47
+ let state: LaunchState = { status: 'idle' }
48
+ const listeners = new Set<() => void>()
49
+ const seen = new Set<string>()
50
+ const queue: { token: string; source: LaunchSource }[] = []
51
+ const deps = slot<ControllerDeps>()
52
+ const navigator = slot<Navigate>()
53
+ let running = false
54
+ let lastScenarioId: string | undefined
55
+
56
+ function setState(next: LaunchState) {
57
+ state = next
58
+ for (const listener of listeners) listener()
59
+ }
60
+
61
+ async function drain() {
62
+ if (running) return
63
+ running = true
64
+ try {
65
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
66
+ const { token, source } = next
67
+ const current = await deps.get()
68
+ setState({ status: 'launching', source })
69
+ const result = await runLaunch(token, {
70
+ ...current,
71
+ navigate: async (path) => (await navigator.get())(path),
72
+ onStage: (stage) => setState({ status: 'launching', source, stage }),
73
+ })
74
+ if (result.ok) lastScenarioId = scenarioIdFromToken(token)
75
+ setState(
76
+ result.ok
77
+ ? { status: 'done', source, path: result.path }
78
+ : { status: 'error', source, error: result.error },
79
+ )
80
+ }
81
+ } finally {
82
+ running = false
83
+ }
84
+ }
85
+
86
+ return {
87
+ /** Queues a launch if `url` carries a token not seen before. Returns whether it did. */
88
+ offer(url: string | null | undefined, source: LaunchSource = 'link'): boolean {
89
+ const token = parseLaunchUrl(url)
90
+ if (!token || seen.has(token)) return false
91
+ seen.add(token)
92
+ queue.push({ token, source })
93
+ void drain()
94
+ return true
95
+ },
96
+ /** Supplies the seed hook URL and adapters; queued launches start once this is called. */
97
+ configure(next: ControllerDeps | undefined) {
98
+ deps.set(next)
99
+ },
100
+ /** Pass the router's navigate once it can navigate; undefined when it unmounts. */
101
+ setNavigator(next: Navigate | undefined) {
102
+ navigator.set(next)
103
+ },
104
+ dismiss() {
105
+ if (state.status === 'error' || state.status === 'done') setState({ status: 'idle' })
106
+ },
107
+ getState: () => state,
108
+ /** The scenario the app was last launched into, for attributing reports. */
109
+ lastScenarioId: () => lastScenarioId,
110
+ subscribe(listener: () => void) {
111
+ listeners.add(listener)
112
+ return () => {
113
+ listeners.delete(listener)
114
+ }
115
+ },
116
+ }
117
+ }
118
+
119
+ export type LaunchController = ReturnType<typeof createLaunchController>
@@ -0,0 +1,128 @@
1
+ /** What a report carries about the moments before it: bounded, scrubbed, no request bodies. */
2
+ export interface Diagnostics {
3
+ screens: { path: string; at: number }[]
4
+ logs: { level: 'error' | 'warn'; message: string; at: number }[]
5
+ requests: { method: string; path: string; status: number; at: number }[]
6
+ }
7
+
8
+ const LIMITS = { screens: 20, logs: 20, requests: 10 }
9
+
10
+ /** Removes credentials from text: query params, Bearer headers and JWT-looking strings. */
11
+ export function redact(text: string): string {
12
+ return text
13
+ .replace(
14
+ /\b(access_token|refresh_token|id_token|token|apikey|api_key|key|password|secret|t)=([^&\s"']+)/gi,
15
+ '$1=[redacted]',
16
+ )
17
+ .replace(/\bBearer\s+[^\s"']+/gi, 'Bearer [redacted]')
18
+ .replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[jwt]')
19
+ }
20
+
21
+ /** Host and path only: query strings often carry tokens or personal data. */
22
+ export function requestPath(url: string): string {
23
+ const [withoutQuery = ''] = url.split(/[?#]/)
24
+ const match = /^[a-z][a-z0-9+.-]*:\/\/([^/]+)(\/.*)?$/i.exec(withoutQuery)
25
+ return redact(match ? `${match[1]}${match[2] ?? '/'}` : withoutQuery)
26
+ }
27
+
28
+ function format(args: unknown[]): string {
29
+ return args
30
+ .map((arg) => {
31
+ if (typeof arg === 'string') return arg
32
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}`
33
+ try {
34
+ return JSON.stringify(arg)
35
+ } catch {
36
+ return String(arg)
37
+ }
38
+ })
39
+ .join(' ')
40
+ }
41
+
42
+ function push<T>(list: T[], item: T, max: number) {
43
+ list.push(item)
44
+ if (list.length > max) list.splice(0, list.length - max)
45
+ }
46
+
47
+ export function createDiagnostics(now: () => number = Date.now) {
48
+ const screens: Diagnostics['screens'] = []
49
+ const logs: Diagnostics['logs'] = []
50
+ const requests: Diagnostics['requests'] = []
51
+
52
+ return {
53
+ recordScreen(path: string) {
54
+ if (screens.at(-1)?.path === path) return
55
+ push(screens, { path: requestPath(path), at: now() }, LIMITS.screens)
56
+ },
57
+ recordLog(level: 'error' | 'warn', args: unknown[]) {
58
+ push(logs, { level, message: redact(format(args)).slice(0, 500), at: now() }, LIMITS.logs)
59
+ },
60
+ recordRequest(method: string, url: string, status: number) {
61
+ push(
62
+ requests,
63
+ {
64
+ method: method.toUpperCase().slice(0, 10),
65
+ path: requestPath(url).slice(0, 300),
66
+ status,
67
+ at: now(),
68
+ },
69
+ LIMITS.requests,
70
+ )
71
+ },
72
+ snapshot(): Diagnostics {
73
+ return { screens: [...screens], logs: [...logs], requests: [...requests] }
74
+ },
75
+ }
76
+ }
77
+
78
+ export type DiagnosticsRecorder = ReturnType<typeof createDiagnostics>
79
+
80
+ interface Hooks {
81
+ console: Pick<Console, 'error' | 'warn'>
82
+ errorUtils?: {
83
+ getGlobalHandler(): (error: unknown, isFatal?: boolean) => void
84
+ setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void
85
+ }
86
+ fetchHost: { fetch: typeof fetch }
87
+ }
88
+
89
+ /**
90
+ * Starts recording console errors and warnings, uncaught errors and failed
91
+ * requests. Each hook calls through to the original, so the app behaves the
92
+ * same. Requests to `ignoreUrl` (Tapthrough itself) are skipped.
93
+ */
94
+ export function installDiagnostics(recorder: DiagnosticsRecorder, hooks: Hooks, ignoreUrl: string) {
95
+ const { console: target, errorUtils, fetchHost } = hooks
96
+
97
+ for (const level of ['error', 'warn'] as const) {
98
+ const original = target[level].bind(target)
99
+ target[level] = (...args: unknown[]) => {
100
+ recorder.recordLog(level, args)
101
+ original(...args)
102
+ }
103
+ }
104
+
105
+ if (errorUtils) {
106
+ const previous = errorUtils.getGlobalHandler()
107
+ errorUtils.setGlobalHandler((error, isFatal) => {
108
+ recorder.recordLog('error', [isFatal ? 'Uncaught (fatal):' : 'Uncaught:', error])
109
+ previous(error, isFatal)
110
+ })
111
+ }
112
+
113
+ const originalFetch = fetchHost.fetch
114
+ fetchHost.fetch = async (input, init) => {
115
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
116
+ const method =
117
+ init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')
118
+ const ignored = url.startsWith(ignoreUrl)
119
+ try {
120
+ const response = await originalFetch(input, init)
121
+ if (!ignored && !response.ok) recorder.recordRequest(method, url, response.status)
122
+ return response
123
+ } catch (error) {
124
+ if (!ignored) recorder.recordRequest(method, url, 0)
125
+ throw error
126
+ }
127
+ }
128
+ }