@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.
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/app.plugin.d.ts +37 -0
- package/app.plugin.js +79 -0
- package/dist/Annotator.d.ts +14 -0
- package/dist/Annotator.d.ts.map +1 -0
- package/dist/Annotator.js +160 -0
- package/dist/Annotator.js.map +1 -0
- package/dist/Bubble.d.ts +27 -0
- package/dist/Bubble.d.ts.map +1 -0
- package/dist/Bubble.js +234 -0
- package/dist/Bubble.js.map +1 -0
- package/dist/Feedback.d.ts +14 -0
- package/dist/Feedback.d.ts.map +1 -0
- package/dist/Feedback.js +977 -0
- package/dist/Feedback.js.map +1 -0
- package/dist/Overlay.d.ts +13 -0
- package/dist/Overlay.d.ts.map +1 -0
- package/dist/Overlay.js +44 -0
- package/dist/Overlay.js.map +1 -0
- package/dist/Popover.d.ts +98 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +531 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Tapthrough.d.ts +27 -0
- package/dist/Tapthrough.d.ts.map +1 -0
- package/dist/Tapthrough.js +111 -0
- package/dist/Tapthrough.js.map +1 -0
- package/dist/core/annotations.d.ts +63 -0
- package/dist/core/annotations.d.ts.map +1 -0
- package/dist/core/annotations.js +76 -0
- package/dist/core/annotations.js.map +1 -0
- package/dist/core/bubble.d.ts +21 -0
- package/dist/core/bubble.d.ts.map +1 -0
- package/dist/core/bubble.js +25 -0
- package/dist/core/bubble.js.map +1 -0
- package/dist/core/controller.d.ts +46 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +103 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/diagnostics.d.ts +47 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +101 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/feedback.d.ts +302 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +345 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/flags.d.ts +13 -0
- package/dist/core/flags.d.ts.map +1 -0
- package/dist/core/flags.js +25 -0
- package/dist/core/flags.js.map +1 -0
- package/dist/core/ideas.d.ts +166 -0
- package/dist/core/ideas.d.ts.map +1 -0
- package/dist/core/ideas.js +159 -0
- package/dist/core/ideas.js.map +1 -0
- package/dist/core/launch.d.ts +43 -0
- package/dist/core/launch.d.ts.map +1 -0
- package/dist/core/launch.js +81 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/reset.d.ts +17 -0
- package/dist/core/reset.d.ts.map +1 -0
- package/dist/core/reset.js +19 -0
- package/dist/core/reset.js.map +1 -0
- package/dist/core/review.d.ts +49 -0
- package/dist/core/review.d.ts.map +1 -0
- package/dist/core/review.js +92 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/url.d.ts +9 -0
- package/dist/core/url.d.ts.map +1 -0
- package/dist/core/url.js +23 -0
- package/dist/core/url.js.map +1 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +13 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/native-intent.d.ts +18 -0
- package/dist/native-intent.d.ts.map +1 -0
- package/dist/native-intent.js +32 -0
- package/dist/native-intent.js.map +1 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +48 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/supabase.d.ts +20 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +18 -0
- package/dist/supabase.js.map +1 -0
- package/dist/ui/native.d.ts +34 -0
- package/dist/ui/native.d.ts.map +1 -0
- package/dist/ui/native.js +76 -0
- package/dist/ui/native.js.map +1 -0
- package/dist/ui/parts.d.ts +62 -0
- package/dist/ui/parts.d.ts.map +1 -0
- package/dist/ui/parts.js +110 -0
- package/dist/ui/parts.js.map +1 -0
- package/dist/ui/theme.d.ts +90 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +61 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +82 -3
- package/src/Annotator.tsx +279 -0
- package/src/Bubble.tsx +331 -0
- package/src/Feedback.tsx +1739 -0
- package/src/Overlay.tsx +61 -0
- package/src/Popover.tsx +1393 -0
- package/src/Tapthrough.tsx +171 -0
- package/src/core/annotations.ts +116 -0
- package/src/core/bubble.ts +45 -0
- package/src/core/controller.ts +119 -0
- package/src/core/diagnostics.ts +128 -0
- package/src/core/feedback.ts +589 -0
- package/src/core/flags.ts +31 -0
- package/src/core/ideas.ts +337 -0
- package/src/core/launch.ts +119 -0
- package/src/core/reset.ts +28 -0
- package/src/core/review.ts +148 -0
- package/src/core/url.ts +21 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +8 -0
- package/src/native-intent.ts +36 -0
- package/src/runtime.ts +30 -0
- package/src/screenshot.ts +64 -0
- package/src/supabase.ts +27 -0
- package/src/ui/native.ts +116 -0
- package/src/ui/parts.tsx +249 -0
- 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
|
+
}
|