@xprem/control-center 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/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @xprem/control-center
2
+
3
+ See which branch this build is running, and switch to another — from the build
4
+ itself.
5
+
6
+ Built for acceptance testing: one TestFlight or Play build becomes a shell for
7
+ every branch that is compatible with it, so five people can test five branches in
8
+ parallel without five builds.
9
+
10
+ ```tsx
11
+ import { ControlCenter } from '@xprem/control-center';
12
+
13
+ export default function App() {
14
+ return (
15
+ <>
16
+ <YourApp />
17
+ <ControlCenter />
18
+ </>
19
+ );
20
+ }
21
+ ```
22
+
23
+ That is the whole integration. There is nothing to configure: the panel reads the
24
+ update URL, app id, channel and runtime version from the config the build already
25
+ carries.
26
+
27
+ ## It turns itself on
28
+
29
+ At launch, once the first frame has painted, the component asks the server one
30
+ question — is branch surfing allowed on this build's channel? On no, it renders
31
+ `null` and registers nothing. On yes, a small blue marker appears on the right
32
+ edge; press it and the panel opens, branches already in hand. One request per
33
+ app session, answered from the server's cache.
34
+
35
+ Turn branch surfing on for a channel from the xprem dashboard and the panel is
36
+ there at the next launch of every build on that channel — nothing to republish.
37
+ Turn it off and it disappears the same way. A production channel never shows it.
38
+
39
+ ## Footprint
40
+
41
+ JavaScript only. No native module, no config plugin, no `prebuild`. It ships in
42
+ the JS bundle, which means **it can be delivered over the air to a build that is
43
+ already in testers' hands** — the picker itself is an update.
44
+
45
+ The only dependencies are `expo-updates` and `expo-constants`, which the app has
46
+ already. The edge marker is the built-in way in; your own trigger works from
47
+ anywhere:
48
+
49
+ ```tsx
50
+ import { openControlCenter } from '@xprem/control-center';
51
+ ```
52
+
53
+ ## What a tester sees
54
+
55
+ The panel opens on the branch currently running — live dot, channel and runtime
56
+ under it — then the branches this build can switch to, drawn as a rail of
57
+ branch dots, newest first. Only branches with an update built for **this** binary's
58
+ runtime version are listed: a branch that changed native code cannot be reached
59
+ without a new build, so offering it would be a dead end.
60
+
61
+ When a branch crashes on launch, expo-updates falls back on its own and the server
62
+ refuses to serve that branch again. The panel says so, names the branch, and says
63
+ what unblocks it — publishing a fix to that same branch.
64
+
65
+ ## Requirements
66
+
67
+ - Expo SDK 54 or newer (`setUpdateRequestHeadersOverride`)
68
+ - `expo-app-id`, `expo-channel-name` and `xprem-branch` declared in
69
+ `updates.requestHeaders` at build time
70
+
71
+ ```ts
72
+ updates: {
73
+ requestHeaders: {
74
+ 'expo-channel-name': 'staging',
75
+ 'expo-app-id': '...',
76
+ 'xprem-branch': '',
77
+ },
78
+ }
79
+ ```
80
+
81
+ Those three are not optional, and the panel refuses to appear without them —
82
+ switching branches replaces the whole header set, and expo-updates only accepts an
83
+ override for keys that existed when the app was built. A build missing one of them
84
+ would drop it from every poll from then on, which the server answers with a 400,
85
+ which means no update can reach the device to undo it. Reinstalling is the only way
86
+ out, so the package checks first and logs which header is missing. `eoas init`
87
+ writes them for you.
88
+
89
+ Declare `expo-channel-name` as a literal, not `process.env.SOMETHING`. The config is
90
+ evaluated when the JS bundle is exported, so an unset variable silently removes the
91
+ key — and an export run with the wrong value would bake in the wrong channel. Only
92
+ the key matters here: the value sent at runtime is always the build's real channel.
93
+
94
+ ## If a build gets stuck
95
+
96
+ It cannot be bricked. A branch that fails to launch is rolled back by
97
+ expo-updates itself, onto the bundle embedded in the binary. To get moving again,
98
+ in order of preference:
99
+
100
+ 1. publish a fix to the branch being tested
101
+ 2. turn branch surfing off for the channel, which returns every device
102
+ 3. reinstall the app, which clears the stored choice
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@xprem/control-center",
3
+ "version": "0.1.0",
4
+ "description": "A branch picker that appears on builds whose channel allows surfing. JS-only, for xprem branch surfing.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "keywords": [
15
+ "xprem",
16
+ "expo",
17
+ "expo-updates",
18
+ "branch-surfing",
19
+ "qa"
20
+ ],
21
+ "license": "MIT",
22
+ "peerDependencies": {
23
+ "expo-constants": "*",
24
+ "expo-updates": ">=0.29.0",
25
+ "react": ">=18",
26
+ "react-native": ">=0.72"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "~19.2.14",
30
+ "typescript": "~6.0.3"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/axelmarciano/xprem.git",
35
+ "directory": "apps/control-center"
36
+ }
37
+ }
@@ -0,0 +1,57 @@
1
+ import { View } from 'react-native';
2
+ import { palette } from './theme';
3
+
4
+ /**
5
+ * The git-branch glyph, drawn with borders instead of an SVG because this package
6
+ * ships no native modules. The curve is the bottom-right quadrant of a box whose
7
+ * corner radius equals its height, which is exactly a quarter circle.
8
+ *
9
+ * Geometry is lucide's 24-unit grid, so it lines up with the icon everyone knows.
10
+ */
11
+ const GRID = 24;
12
+
13
+ export function BranchIcon({ size = 24, color = palette.ink, weight = 2 }) {
14
+ const s = size / GRID;
15
+ const stroke = weight * s;
16
+ const dot = (cx: number, cy: number) => ({
17
+ position: 'absolute' as const,
18
+ left: (cx - 3) * s,
19
+ top: (cy - 3) * s,
20
+ width: 6 * s,
21
+ height: 6 * s,
22
+ borderRadius: 3 * s,
23
+ borderWidth: stroke,
24
+ borderColor: color,
25
+ });
26
+
27
+ return (
28
+ <View style={{ width: size, height: size }}>
29
+ <View
30
+ style={{
31
+ position: 'absolute',
32
+ left: (6 - weight / 2) * s,
33
+ top: 4 * s,
34
+ width: stroke,
35
+ height: 16 * s,
36
+ backgroundColor: color,
37
+ }}
38
+ />
39
+ <View
40
+ style={{
41
+ position: 'absolute',
42
+ left: 6 * s,
43
+ top: 7 * s,
44
+ width: 12 * s,
45
+ height: 6 * s,
46
+ borderBottomWidth: stroke,
47
+ borderRightWidth: stroke,
48
+ borderColor: color,
49
+ borderBottomRightRadius: 6 * s,
50
+ }}
51
+ />
52
+ <View style={dot(6, 4)} />
53
+ <View style={dot(6, 20)} />
54
+ <View style={dot(18, 4)} />
55
+ </View>
56
+ );
57
+ }
@@ -0,0 +1,521 @@
1
+ import { Component, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ InteractionManager,
5
+ Modal,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ } from 'react-native';
13
+ import { BranchIcon } from './BranchIcon';
14
+ import { readConfig, readLoadedState, SurfConfig } from './config';
15
+ import { BranchPage, listBranches, surfTo } from './surf';
16
+ import { cardShadow, palette, radius, space, type } from './theme';
17
+ import { XpremMark } from './XpremMark';
18
+
19
+ /** "5 min ago" beats a timestamp for someone deciding what is fresh enough to test. */
20
+ function sinceLabel(iso: string): string {
21
+ const published = Date.parse(iso);
22
+ if (Number.isNaN(published)) {
23
+ return '';
24
+ }
25
+ const minutes = Math.max(0, Math.round((Date.now() - published) / 60000));
26
+ if (minutes < 1) return 'Updated just now';
27
+ if (minutes < 60) return `Updated ${minutes} min ago`;
28
+ const hours = Math.round(minutes / 60);
29
+ if (hours < 24) return `Updated ${hours} hr ago`;
30
+ const days = Math.round(hours / 24);
31
+ return days === 1 ? 'Updated yesterday' : `Updated ${days} days ago`;
32
+ }
33
+
34
+ /**
35
+ * One probe per JS session. Memoised at module scope rather than in the component:
36
+ * a useEffect would run again if the tree remounted, and this must be exactly once
37
+ * per app open. reloadAsync starts a new session, so a surf re-probes naturally.
38
+ *
39
+ * Whether a channel allows surfing is a setting that changes whenever an admin
40
+ * says so, while a manifest is a snapshot frozen when its update was served — so
41
+ * the answer cannot ride in the manifest, or enabling the feature would only reach
42
+ * devices that happen to download something afterwards.
43
+ */
44
+ let sessionProbe: Promise<BranchPage | null> | null = null;
45
+
46
+ function probeOnce(config: SurfConfig): Promise<BranchPage | null> {
47
+ // Only an ANSWER is remembered — a list, or the 404 that means surfing is off.
48
+ // A timeout is not an answer: caching one would disable the picker until the
49
+ // app is killed, and the tester has no way to know a retry would work.
50
+ sessionProbe ??= listBranches(config).catch(error => {
51
+ sessionProbe = null;
52
+ throw error;
53
+ });
54
+ return sessionProbe;
55
+ }
56
+
57
+ let openPanel: (() => void) | null = null;
58
+
59
+ /** Opens the panel from anywhere — the edge handle is the built-in trigger. */
60
+ export function openControlCenter() {
61
+ openPanel?.();
62
+ }
63
+
64
+ // A QA tool has no business taking the host app down: whatever throws inside the
65
+ // panel unmounts the panel, never the app around it.
66
+ class ControlCenterBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
67
+ state = { failed: false };
68
+
69
+ static getDerivedStateFromError() {
70
+ return { failed: true };
71
+ }
72
+
73
+ componentDidCatch(error: Error) {
74
+ console.warn(`[xprem] The control center crashed and was unmounted: ${error.message}`);
75
+ }
76
+
77
+ render() {
78
+ return this.state.failed ? null : this.props.children;
79
+ }
80
+ }
81
+
82
+ export function ControlCenter() {
83
+ return (
84
+ <ControlCenterBoundary>
85
+ <ControlCenterPanel />
86
+ </ControlCenterBoundary>
87
+ );
88
+ }
89
+
90
+ function ControlCenterPanel() {
91
+ const config = useMemo(readConfig, []);
92
+ const loaded = useMemo(readLoadedState, []);
93
+ const [visible, setVisible] = useState(false);
94
+ const [page, setPage] = useState<BranchPage | null>(null);
95
+ const [error, setError] = useState<string | null>(null);
96
+ const [pending, setPending] = useState<string | null>(null);
97
+ const [note, setNote] = useState<string | null>(null);
98
+ const [query, setQuery] = useState('');
99
+ const [expanding, setExpanding] = useState(false);
100
+ const [expanded, setExpanded] = useState(false);
101
+
102
+ const [allowed, setAllowed] = useState(false);
103
+ const active = config !== null && allowed;
104
+ const open = useCallback(() => setVisible(true), []);
105
+ const close = useCallback(() => setVisible(false), []);
106
+
107
+ // After first paint: a QA tool must not sit on the critical path of a launch.
108
+ useEffect(() => {
109
+ if (!config) return;
110
+ let cancelled = false;
111
+ const task = InteractionManager.runAfterInteractions(() => {
112
+ probeOnce(config)
113
+ .then(result => {
114
+ if (cancelled || result === null) return;
115
+ setAllowed(true);
116
+ setPage(result);
117
+ })
118
+ .catch((cause: Error) => {
119
+ // Not silent: the panel is unreachable for the rest of this session
120
+ // unless the host app calls openControlCenter, and nothing else would
121
+ // ever say why.
122
+ console.warn(`[xprem] Could not reach the branch list: ${cause.message}`);
123
+ });
124
+ });
125
+ return () => {
126
+ cancelled = true;
127
+ task.cancel();
128
+ };
129
+ }, [config]);
130
+
131
+ useEffect(() => {
132
+ // Registered whether or not the probe has answered. Gating it on `active`
133
+ // made the host app's own trigger a silent no-op for the whole of every
134
+ // launch, and permanently so whenever the probe failed — which is exactly
135
+ // when someone reaches for it. Opening early shows the panel's own loading
136
+ // and error states, which is the point.
137
+ openPanel = open;
138
+ return () => {
139
+ // Only if it is still ours: a second instance may have taken over, and
140
+ // clearing unconditionally would deregister a panel that is still mounted.
141
+ if (openPanel === open) {
142
+ openPanel = null;
143
+ }
144
+ };
145
+ }, [open]);
146
+
147
+ useEffect(() => {
148
+ if (!visible || !config) return;
149
+ const controller = new AbortController();
150
+ setError(null);
151
+ setNote(null);
152
+ setQuery('');
153
+ setExpanding(false);
154
+ setExpanded(false);
155
+ listBranches(config, controller.signal)
156
+ .then(result => {
157
+ if (result === null) {
158
+ // Surfing was turned off while this session was running. Stand down
159
+ // rather than spin: null reads as "loading" everywhere below.
160
+ setAllowed(false);
161
+ setVisible(false);
162
+ return;
163
+ }
164
+ setPage(result);
165
+ })
166
+ .catch((cause: Error) => {
167
+ if (cause.name !== 'AbortError') setError(cause.message);
168
+ });
169
+ return () => controller.abort();
170
+ }, [visible, config]);
171
+
172
+ const showEverything = useCallback(async () => {
173
+ if (!config || expanded || expanding) return;
174
+ setExpanding(true);
175
+ try {
176
+ const result = await listBranches(config, undefined, true);
177
+ if (result === null) {
178
+ // Surfing was switched off between opening the panel and asking for the
179
+ // rest. Same stand-down as every other read, or the tester is left
180
+ // holding switches the server will no longer honour.
181
+ setAllowed(false);
182
+ setVisible(false);
183
+ return;
184
+ }
185
+ setPage(result);
186
+ // Set even when the wide answer is itself capped: this only means "already
187
+ // asked", so a keystroke cannot start the same fetch again. Whether the
188
+ // list is COMPLETE is a separate question, read off total below.
189
+ setExpanded(true);
190
+ } catch (cause) {
191
+ setError((cause as Error).message);
192
+ } finally {
193
+ setExpanding(false);
194
+ }
195
+ }, [config, expanded, expanding]);
196
+
197
+ const search = useCallback(
198
+ (text: string) => {
199
+ setQuery(text);
200
+ // Searching a partial list would answer "no match" for a branch that is
201
+ // merely further down — a wrong answer, not a short one. So the first
202
+ // keystroke pulls the rest; showEverything is a no-op once it has.
203
+ if (text.length > 0) void showEverything();
204
+ },
205
+ [showEverything]
206
+ );
207
+
208
+ const switchTo = useCallback(
209
+ async (branch: string | null) => {
210
+ if (!config) return;
211
+ setPending(branch ?? '');
212
+ setNote(null);
213
+ try {
214
+ const outcome = await surfTo(config, branch);
215
+ if (outcome === 'nothing-to-load') {
216
+ setNote(
217
+ branch
218
+ ? `Switched to ${branch}. It has nothing newer than what is already ` +
219
+ `running, so the screen did not change — you will get its next publish.`
220
+ : 'Back on this build\u2019s own branch. Nothing newer to load.'
221
+ );
222
+ }
223
+ } catch (cause) {
224
+ setError((cause as Error).message);
225
+ } finally {
226
+ setPending(null);
227
+ }
228
+ },
229
+ [config]
230
+ );
231
+
232
+ if (!active || !config) {
233
+ return null;
234
+ }
235
+
236
+ // What is RUNNING, which after a surf is the surfed branch — not the branch the
237
+ // channel maps to. Excluded from the list below: there is nothing to switch to.
238
+ const loadedBranch = loaded.branch;
239
+ const rows = (page?.branches ?? []).filter(candidate => candidate.name !== loadedBranch);
240
+ // The server sends a short page of the newest branches. Say how many are left
241
+ // rather than letting the list pass for the whole truth.
242
+ const withheld = page ? page.total - page.branches.length : 0;
243
+ // Keyed on the total, not the page: the page is short by design, so counting
244
+ // loaded rows would hide the search field exactly when it is most needed.
245
+ const searchable = (page?.total ?? 0) > 10;
246
+ const needle = query.trim().toLowerCase();
247
+ const shown = needle ? rows.filter(r => r.name.toLowerCase().includes(needle)) : rows;
248
+
249
+ if (!visible) {
250
+ // Always present while surfing is allowed: the way in a tester nobody briefed
251
+ // will actually find. An edge sliver rather than an invisible corner target,
252
+ // so it never swallows the host app's own controls.
253
+ return (
254
+ <Pressable
255
+ style={styles.handle}
256
+ onPress={open}
257
+ hitSlop={{ top: 12, bottom: 12, left: 16, right: 8 }}
258
+ accessibilityRole="button"
259
+ accessibilityLabel="Open the branch picker">
260
+ <View style={styles.handleGrip} />
261
+ </Pressable>
262
+ );
263
+ }
264
+
265
+ return (
266
+ // pageSheet is the native card the dev menu uses: full-height sheet, the app
267
+ // pushed back behind it, swipe down to dismiss. No custom animation to own.
268
+ <Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={close}>
269
+ <View style={styles.screen}>
270
+ <View style={styles.header}>
271
+ <View style={styles.heroLine}>
272
+ <BranchIcon size={26} weight={2.2} />
273
+ <Text style={[type.hero, styles.heroName]} numberOfLines={1}>
274
+ {loadedBranch ?? 'Embedded build'}
275
+ </Text>
276
+ <Pressable
277
+ style={({ pressed }) => [styles.close, pressed && styles.closePressed]}
278
+ onPress={close}
279
+ accessibilityRole="button"
280
+ accessibilityLabel="Close">
281
+ <Text style={styles.closeGlyph}>✕</Text>
282
+ </Pressable>
283
+ </View>
284
+
285
+ <View style={styles.chipLine}>
286
+ <Text style={type.label}>Running on:</Text>
287
+ <View style={styles.chip}>
288
+ <View style={styles.liveDot} />
289
+ <Text style={type.chip}>Channel: {config.channel}</Text>
290
+ </View>
291
+ <View style={[styles.chip, styles.chipNeutral]}>
292
+ <Text style={[type.chip, styles.chipNeutralInk]}>
293
+ Runtime {config.runtimeVersion}
294
+ </Text>
295
+ </View>
296
+ </View>
297
+ </View>
298
+
299
+ <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>
300
+ {loaded.refusedBranch && (
301
+ <View style={styles.warnCard}>
302
+ <Text style={styles.warnTitle}>{loaded.refusedBranch} was rolled back</Text>
303
+ <Text style={styles.warnBody}>
304
+ It crashed on launch, so this build came back here. Publishing a fix to
305
+ that branch makes it available again.
306
+ </Text>
307
+ </View>
308
+ )}
309
+
310
+ <Text style={[type.section, styles.sectionTitle]}>Switch to</Text>
311
+
312
+ {searchable && (
313
+ <TextInput
314
+ style={styles.search}
315
+ value={query}
316
+ onChangeText={search}
317
+ placeholder="Search branches"
318
+ placeholderTextColor={palette.muted}
319
+ autoCapitalize="none"
320
+ autoCorrect={false}
321
+ clearButtonMode="while-editing"
322
+ accessibilityLabel="Search branches"
323
+ />
324
+ )}
325
+
326
+ {page === null && (
327
+ <View style={[styles.card, styles.cardCentered]}>
328
+ <ActivityIndicator color={palette.muted} />
329
+ </View>
330
+ )}
331
+
332
+ {page !== null && shown.length === 0 && (
333
+ <View style={[styles.card, styles.cardCentered]}>
334
+ <Text style={type.meta}>
335
+ {needle
336
+ ? `No branch matches “${query.trim()}”.`
337
+ : `Nothing else is published for runtime ${config.runtimeVersion}.`}
338
+ </Text>
339
+ </View>
340
+ )}
341
+
342
+ {shown.map(candidate => (
343
+ <Pressable
344
+ key={candidate.name}
345
+ style={({ pressed }) => [styles.card, styles.row, pressed && styles.rowPressed]}
346
+ disabled={pending !== null}
347
+ onPress={() => void switchTo(candidate.name)}>
348
+ <BranchIcon size={22} color={palette.muted} />
349
+ <View style={styles.rowText}>
350
+ <Text style={type.rowTitle} numberOfLines={1}>
351
+ {candidate.name}
352
+ </Text>
353
+ <Text style={type.meta}>{sinceLabel(candidate.lastUpdateAt)}</Text>
354
+ </View>
355
+ {pending === candidate.name ? (
356
+ <ActivityIndicator size="small" color={palette.muted} />
357
+ ) : (
358
+ <Text style={styles.chevron}>›</Text>
359
+ )}
360
+ </Pressable>
361
+ ))}
362
+
363
+ {withheld > 0 && !expanded && (
364
+ <Pressable
365
+ style={({ pressed }) => [styles.card, styles.seeAll, pressed && styles.rowPressed]}
366
+ disabled={expanding}
367
+ onPress={() => void showEverything()}>
368
+ {expanding ? (
369
+ <ActivityIndicator size="small" color={palette.muted} />
370
+ ) : (
371
+ <Text style={styles.seeAllText}>
372
+ Showing the {page?.branches.length} newest · See all {page?.total}
373
+ </Text>
374
+ )}
375
+ </Pressable>
376
+ )}
377
+
378
+ {withheld > 0 && expanded && (
379
+ // The server will not send more than this. Saying so is the whole
380
+ // point: a search over a list this size must not read as exhaustive.
381
+ <View style={[styles.card, styles.seeAll]}>
382
+ <Text style={type.meta}>
383
+ Showing the {page?.branches.length} newest of {page?.total}. Older
384
+ branches are not listed.
385
+ </Text>
386
+ </View>
387
+ )}
388
+
389
+ {note && <Text style={[type.meta, styles.footnote]}>{note}</Text>}
390
+ {error && <Text style={[type.meta, styles.errorText]}>{error}</Text>}
391
+
392
+ <Pressable
393
+ style={({ pressed }) => [styles.pill, pressed && styles.pillPressed]}
394
+ disabled={pending !== null}
395
+ onPress={() => void switchTo(null)}>
396
+ {pending === '' ? (
397
+ <ActivityIndicator size="small" color={palette.pillInk} />
398
+ ) : (
399
+ <Text style={type.pill}>Return to this build&rsquo;s branch</Text>
400
+ )}
401
+ </Pressable>
402
+
403
+ <View style={styles.brand}>
404
+ <XpremMark size={16} />
405
+ <Text style={type.meta}>xprem</Text>
406
+ </View>
407
+ </ScrollView>
408
+ </View>
409
+ </Modal>
410
+ );
411
+ }
412
+
413
+ const styles = StyleSheet.create({
414
+ handle: {
415
+ position: 'absolute',
416
+ right: 0,
417
+ top: '45%',
418
+ paddingVertical: space.md,
419
+ paddingLeft: space.sm,
420
+ paddingRight: 2,
421
+ },
422
+ handleGrip: {
423
+ width: 4,
424
+ height: 42,
425
+ borderTopLeftRadius: 3,
426
+ borderBottomLeftRadius: 3,
427
+ backgroundColor: palette.ink,
428
+ opacity: 0.35,
429
+ },
430
+ screen: { flex: 1, backgroundColor: palette.page },
431
+ header: {
432
+ backgroundColor: palette.surface,
433
+ paddingHorizontal: space.lg,
434
+ paddingTop: space.lg,
435
+ paddingBottom: space.md,
436
+ borderBottomWidth: StyleSheet.hairlineWidth,
437
+ borderBottomColor: palette.border,
438
+ gap: space.md,
439
+ },
440
+ heroLine: { flexDirection: 'row', alignItems: 'center', gap: 10 },
441
+ heroName: { flex: 1 },
442
+ close: {
443
+ width: 32,
444
+ height: 32,
445
+ borderRadius: 16,
446
+ backgroundColor: palette.page,
447
+ alignItems: 'center',
448
+ justifyContent: 'center',
449
+ },
450
+ closePressed: { backgroundColor: palette.surfacePressed },
451
+ closeGlyph: { fontSize: 15, color: palette.muted },
452
+ chipLine: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: space.sm },
453
+ chip: {
454
+ flexDirection: 'row',
455
+ alignItems: 'center',
456
+ gap: 6,
457
+ backgroundColor: palette.chipBg,
458
+ borderRadius: radius.chip,
459
+ paddingHorizontal: 10,
460
+ paddingVertical: 5,
461
+ },
462
+ chipNeutral: { backgroundColor: palette.page },
463
+ chipNeutralInk: { color: palette.muted },
464
+ liveDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: palette.live },
465
+ body: { flex: 1 },
466
+ bodyContent: { padding: space.md, paddingBottom: space.xl, gap: space.sm },
467
+ sectionTitle: { marginTop: space.sm, marginLeft: space.xs },
468
+ search: {
469
+ backgroundColor: palette.surface,
470
+ borderRadius: radius.chip + 2,
471
+ borderWidth: StyleSheet.hairlineWidth,
472
+ borderColor: palette.border,
473
+ minHeight: 42,
474
+ paddingHorizontal: space.md,
475
+ fontSize: 16,
476
+ color: palette.ink,
477
+ },
478
+ card: {
479
+ backgroundColor: palette.surface,
480
+ borderRadius: radius.card,
481
+ paddingHorizontal: space.md,
482
+ paddingVertical: 14,
483
+ ...cardShadow,
484
+ },
485
+ cardCentered: { alignItems: 'center', justifyContent: 'center', minHeight: 64 },
486
+ row: { flexDirection: 'row', alignItems: 'center', gap: space.sm + 4 },
487
+ rowPressed: { backgroundColor: palette.surfacePressed },
488
+ seeAll: { alignItems: 'center', justifyContent: 'center', minHeight: 48 },
489
+ seeAllText: { fontSize: 15, fontWeight: '600', color: palette.ink },
490
+ rowText: { flex: 1, gap: 2 },
491
+ chevron: { fontSize: 22, color: palette.muted, marginTop: -2 },
492
+ warnCard: {
493
+ backgroundColor: palette.warnBg,
494
+ borderRadius: radius.card,
495
+ padding: space.md,
496
+ gap: 4,
497
+ },
498
+ warnTitle: { fontSize: 16, fontWeight: '600', color: palette.warnInk },
499
+ warnBody: { fontSize: 14, lineHeight: 20, color: palette.warnInk },
500
+ footnote: { marginLeft: space.xs },
501
+ errorText: { marginLeft: space.xs, color: palette.danger },
502
+ pill: {
503
+ marginTop: space.md,
504
+ minHeight: 52,
505
+ borderRadius: radius.pill,
506
+ backgroundColor: palette.pill,
507
+ alignItems: 'center',
508
+ justifyContent: 'center',
509
+ },
510
+ pillPressed: { opacity: 0.85 },
511
+ brand: {
512
+ flexDirection: 'row',
513
+ alignItems: 'center',
514
+ justifyContent: 'center',
515
+ gap: 6,
516
+ marginTop: space.lg,
517
+ opacity: 0.5,
518
+ },
519
+ });
520
+
521
+ export default ControlCenter;
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View } from 'react-native';
2
+
3
+ /**
4
+ * The xprem mark — the X glyph and its azure dot on the dark tile — rebuilt from
5
+ * plain Views because the design of this package forbids native modules and
6
+ * react-native-svg is one. Every shape in the mark is a rounded rect or a
7
+ * circle, so nothing is lost except the dot's radial gradient, flattened to its
8
+ * dominant stop; at footer size the difference does not survive the screen.
9
+ *
10
+ * Geometry is the SVG's, scaled from its 64-unit viewBox. Colours are baked in,
11
+ * exactly like the dashboard's mark: the tile must stay identical everywhere.
12
+ */
13
+ const VIEWBOX = 64;
14
+ // The strokes run (18,22)–(38,46) and (38,22)–(18,46): not a 45° X, slightly
15
+ // taller than wide, and that asymmetry is part of the mark.
16
+ const STROKE = 6.5;
17
+ const STROKE_LENGTH = Math.hypot(38 - 18, 46 - 22) + STROKE; // round caps add a radius each
18
+ const STROKE_ANGLE = (Math.atan2(46 - 22, 38 - 18) * 180) / Math.PI;
19
+
20
+ export function XpremMark({ size = 16 }: { size?: number }) {
21
+ const s = size / VIEWBOX;
22
+ const bar = {
23
+ position: 'absolute' as const,
24
+ width: STROKE_LENGTH * s,
25
+ height: STROKE * s,
26
+ borderRadius: (STROKE / 2) * s,
27
+ backgroundColor: '#EEF2FA',
28
+ left: 28 * s - (STROKE_LENGTH * s) / 2,
29
+ top: 34 * s - (STROKE * s) / 2,
30
+ };
31
+ return (
32
+ <View
33
+ style={{
34
+ width: size,
35
+ height: size,
36
+ borderRadius: 14 * s,
37
+ backgroundColor: '#0A0E16',
38
+ borderWidth: StyleSheet.hairlineWidth,
39
+ borderColor: '#232F42',
40
+ }}
41
+ accessibilityRole="image"
42
+ accessibilityLabel="xprem">
43
+ <View style={[bar, { transform: [{ rotate: `${STROKE_ANGLE}deg` }] }]} />
44
+ <View style={[bar, { transform: [{ rotate: `${-STROKE_ANGLE}deg` }] }]} />
45
+ <View
46
+ style={{
47
+ position: 'absolute',
48
+ width: 13 * s,
49
+ height: 13 * s,
50
+ borderRadius: 6.5 * s,
51
+ backgroundColor: '#4E97F2',
52
+ left: (47 - 6.5) * s,
53
+ top: (43 - 6.5) * s,
54
+ }}
55
+ />
56
+ </View>
57
+ );
58
+ }
package/src/config.ts ADDED
@@ -0,0 +1,113 @@
1
+ import Constants from 'expo-constants';
2
+ import * as Updates from 'expo-updates';
3
+
4
+ /** What the update server needs to answer this device, all of it already on hand. */
5
+ export type SurfConfig = {
6
+ /** Where /branch_lists lives: the update URL with its last segment dropped. */
7
+ baseUrl: string;
8
+ appId: string;
9
+ channel: string;
10
+ runtimeVersion: string;
11
+ /**
12
+ * The request headers baked at build time. setUpdateRequestHeadersOverride
13
+ * REPLACES the whole set rather than merging into it, and expo-updates only
14
+ * accepts keys that were declared at build time — so every override has to be
15
+ * rebuilt from this, or the poll loses its channel and its app id.
16
+ */
17
+ requestHeaders: Record<string, string>;
18
+ };
19
+
20
+ export const BRANCH_HEADER = 'xprem-branch';
21
+
22
+ /** State the manifest already carries, so nothing has to be stored on the side. */
23
+ export type LoadedState = {
24
+ /** The branch actually served. Not necessarily the one that was asked for. */
25
+ branch: string | null;
26
+ /** Set when the server refused a branch because its update crashed here. */
27
+ refusedBranch: string | null;
28
+ };
29
+
30
+ function extra(): Record<string, unknown> {
31
+ return ((Updates.manifest as { extra?: Record<string, unknown> } | undefined)?.extra ??
32
+ {}) as Record<string, unknown>;
33
+ }
34
+
35
+ export function readLoadedState(): LoadedState {
36
+ const manifestExtra = extra();
37
+ return {
38
+ branch: typeof manifestExtra.branch === 'string' ? manifestExtra.branch : null,
39
+ refusedBranch:
40
+ typeof manifestExtra.branchSurfingRefused === 'string' &&
41
+ manifestExtra.branchSurfingRefused.length > 0
42
+ ? manifestExtra.branchSurfingRefused
43
+ : null,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The keys the override cannot be built without. expo-updates only accepts an
49
+ * override whose keys were declared at build time, and the override replaces the
50
+ * whole header set — so a build missing any of these can be sent into a state
51
+ * where every poll is answered 400 and no update can reach it to fix that.
52
+ */
53
+ const REQUIRED_BUILD_HEADERS = ['expo-app-id', 'expo-channel-name', BRANCH_HEADER];
54
+
55
+ function declared(requestHeaders: Record<string, string>, key: string): boolean {
56
+ return typeof requestHeaders[key] === 'string';
57
+ }
58
+
59
+ /**
60
+ * Returns null when this build cannot surf at all: no update config, a runtime too
61
+ * old for the header override, or a build-time header set the override could not
62
+ * be rebuilt from. The component renders nothing in that case rather than offering
63
+ * a switch that would strand the device.
64
+ */
65
+ export function readConfig(): SurfConfig | null {
66
+ const updates = Constants.expoConfig?.updates as
67
+ | { url?: string; requestHeaders?: Record<string, string> }
68
+ | undefined;
69
+ if (!updates?.url || typeof Updates.setUpdateRequestHeadersOverride !== 'function') {
70
+ return null;
71
+ }
72
+
73
+ const requestHeaders = updates.requestHeaders ?? {};
74
+ // A key whose value came from an unset env var is dropped from the config
75
+ // entirely, so this checks presence rather than truthiness — the empty string
76
+ // is a declaration, undefined is not.
77
+ const missing = REQUIRED_BUILD_HEADERS.filter(key => !declared(requestHeaders, key));
78
+ if (missing.length > 0) {
79
+ // Loud, because the symptom is a panel that never appears and the cause is
80
+ // three lines away in app config.
81
+ console.warn(
82
+ `[xprem] Branch surfing is unavailable: ${missing.join(', ')} ${
83
+ missing.length === 1 ? 'is' : 'are'
84
+ } missing from updates.requestHeaders. A header can only be overridden at ` +
85
+ `runtime if it was declared at build time.`
86
+ );
87
+ return null;
88
+ }
89
+
90
+ const appId = requestHeaders['expo-app-id'];
91
+ const channel = Updates.channel;
92
+ const runtimeVersion = Updates.runtimeVersion;
93
+ if (!appId || !channel || !runtimeVersion) {
94
+ return null;
95
+ }
96
+
97
+ let baseUrl: string;
98
+ try {
99
+ // Sibling of the manifest route, not root of the host: a self-hosted server
100
+ // at https://host/ota/manifest serves https://host/ota/branch_lists. Using
101
+ // the origin alone sent every request to the wrong path, and the 404 that
102
+ // came back was indistinguishable from "this channel does not allow it".
103
+ const parsed = new URL(updates.url);
104
+ // Trailing slashes go first: on ".../manifest/" the segment removal would
105
+ // otherwise eat the empty part after the slash and leave "manifest" in the
106
+ // path, sending every request one level too deep.
107
+ baseUrl = parsed.origin + parsed.pathname.replace(/\/+$/, '').replace(/\/[^/]*$/, '');
108
+ } catch {
109
+ return null;
110
+ }
111
+
112
+ return { baseUrl, appId, channel, runtimeVersion, requestHeaders };
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { ControlCenter, openControlCenter } from './ControlCenter';
2
+ export type { SurfableBranch } from './surf';
package/src/surf.ts ADDED
@@ -0,0 +1,152 @@
1
+ import * as Updates from 'expo-updates';
2
+ import { Platform } from 'react-native';
3
+ import { BRANCH_HEADER, SurfConfig } from './config';
4
+
5
+ export type SurfableBranch = {
6
+ name: string;
7
+ lastUpdateAt: string;
8
+ };
9
+
10
+ /** A page of branches, plus how many matched in all, so the panel can offer the rest. */
11
+ /** Set by the server on a 404 it decided, so a proxy's 404 is not mistaken for one. */
12
+ const SURFING_DISABLED_HEADER = 'xprem-branch-surfing';
13
+
14
+ export type BranchPage = {
15
+ branches: SurfableBranch[];
16
+ total: number;
17
+ };
18
+
19
+ /**
20
+ * Asks the server which branches this build may be served. Returns null when the
21
+ * channel does not allow surfing at all — distinct from an empty page, which
22
+ * means it does but has nothing else built for this runtime version. The panel
23
+ * hides itself on null and shows an empty state on [].
24
+ *
25
+ * The default answer is the newest 50; pass all to ask for the rest, which the
26
+ * panel only does when a tester taps for it.
27
+ */
28
+ export async function listBranches(
29
+ config: SurfConfig,
30
+ signal?: AbortSignal,
31
+ all = false
32
+ ): Promise<BranchPage | null> {
33
+ const response = await fetch(`${config.baseUrl}/branch_lists${all ? '?all=1' : ''}`, {
34
+ method: 'GET',
35
+ headers: {
36
+ 'expo-app-id': config.appId,
37
+ 'expo-channel-name': config.channel,
38
+ 'expo-runtime-version': config.runtimeVersion,
39
+ // The list is per platform, like the manifest: a branch whose only update
40
+ // is for the other one cannot be served here, so it must not be offered.
41
+ 'expo-platform': Platform.OS,
42
+ },
43
+ signal,
44
+ });
45
+ if (response.status === 404) {
46
+ // Turning surfing off for a channel has to reach the devices already pinned
47
+ // to a branch, and this is the only place it can: the panel hides itself on
48
+ // this answer, so after it does there is no interface left to unpin from.
49
+ // Left pinned, a device would silently resume surfing the moment surfing was
50
+ // switched back on, onto a branch nobody chose in that session.
51
+ //
52
+ // Gated on the server saying so, not on the status alone: any 404 used to
53
+ // trigger this, so an old server, a proxy, or a path-based deployment wiped
54
+ // the tester's branch at every launch with nothing said anywhere.
55
+ if (response.headers.get(SURFING_DISABLED_HEADER) !== null) {
56
+ pin(config, null);
57
+ }
58
+ return null;
59
+ }
60
+ if (!response.ok) {
61
+ throw new Error(`Could not reach the update server (${response.status}).`);
62
+ }
63
+ const page = (await response.json()) as BranchPage;
64
+ // The build carrying this code outlives the server it was written against, so
65
+ // a body of another shape hides the panel rather than crashing the host app.
66
+ if (!page || !Array.isArray(page.branches) || typeof page.total !== 'number') {
67
+ return null;
68
+ }
69
+ return page;
70
+ }
71
+
72
+ /**
73
+ * Rebuilds the WHOLE header set with one value changed: the override replaces
74
+ * the native set and persists across relaunches.
75
+ *
76
+ * The channel and app id are pinned from the running values rather than trusted
77
+ * from Constants.expoConfig: that copy is evaluated when the JS bundle is
78
+ * exported, and a config spelled `process.env.RELEASE_CHANNEL` evaluates to
79
+ * undefined in any bundle exported without the variable. Spreading it then
80
+ * strips the channel out of every future poll — observed live as /manifest
81
+ * answering 400 "No channel name provided" until the override is cleared.
82
+ */
83
+ /**
84
+ * What this session last pinned, so a surf that fails partway can put it back.
85
+ * undefined means "not set by us": the override may still hold something from an
86
+ * earlier session, which nothing on the client can read back — expo-updates
87
+ * exposes no getter — so the only honest restore in that case is to clear it.
88
+ */
89
+ let pinnedBranch: string | null | undefined;
90
+
91
+ function pin(config: SurfConfig, branch: string | null) {
92
+ if (branch === null) {
93
+ // Not "override with an empty value" — no override at all. The native side
94
+ // reverts to the headers baked at build time, the one state that cannot be
95
+ // wrong.
96
+ Updates.setUpdateRequestHeadersOverride(null);
97
+ } else {
98
+ applyBranchHeader(config, branch);
99
+ }
100
+ pinnedBranch = branch;
101
+ }
102
+
103
+ function applyBranchHeader(config: SurfConfig, branch: string) {
104
+ const headers: Record<string, string> = {};
105
+ for (const [key, value] of Object.entries(config.requestHeaders)) {
106
+ // Empty values are dropped, and that is load-bearing rather than tidy.
107
+ // expo-updates applies this header set LAST, after the server-defined
108
+ // headers it has stored, and each entry REPLACES rather than adds — so an
109
+ // empty xprem-surf-blocked declared in app config would wipe the refusal
110
+ // verdicts on every poll and let a crashing update be served again.
111
+ if (typeof value === 'string' && value !== '') {
112
+ headers[key] = value;
113
+ }
114
+ }
115
+ headers['expo-channel-name'] = config.channel;
116
+ headers['expo-app-id'] = config.appId;
117
+ headers[BRANCH_HEADER] = branch;
118
+ Updates.setUpdateRequestHeadersOverride(headers);
119
+ }
120
+
121
+ export type SurfOutcome = 'reloading' | 'nothing-to-load';
122
+
123
+ /**
124
+ * Points this device at a branch and reloads onto it. Returns without reloading
125
+ * when the server has nothing new: the caller says so rather than leaving the
126
+ * tester looking at an unchanged screen.
127
+ */
128
+ export async function surfTo(config: SurfConfig, branch: string | null): Promise<SurfOutcome> {
129
+ // The override has to be in place before checkForUpdateAsync, since that is
130
+ // the request it governs — so a failure after this line leaves the device
131
+ // pinned to a branch it never loaded, persistently and across relaunches,
132
+ // while the panel still shows the old one. Whatever happens below, the device
133
+ // must not be left pointing somewhere the tester was not told about.
134
+ const previous = pinnedBranch;
135
+ pin(config, branch);
136
+ try {
137
+ return await runSurf(config);
138
+ } catch (cause) {
139
+ pin(config, previous ?? null);
140
+ throw cause;
141
+ }
142
+ }
143
+
144
+ async function runSurf(_config: SurfConfig): Promise<SurfOutcome> {
145
+ const { isAvailable } = await Updates.checkForUpdateAsync();
146
+ if (!isAvailable) {
147
+ return 'nothing-to-load';
148
+ }
149
+ await Updates.fetchUpdateAsync();
150
+ await Updates.reloadAsync();
151
+ return 'reloading';
152
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * White cards floating on a light page, bold near-black type, one black pill for
3
+ * the primary action — the register of the Expo dashboard's branch page, which is
4
+ * where these testers already read about branches.
5
+ */
6
+ export const palette = {
7
+ page: '#F6F7F9',
8
+ surface: '#FFFFFF',
9
+ surfacePressed: '#EFF1F4',
10
+ border: 'rgba(17, 24, 39, 0.07)',
11
+ ink: '#111418',
12
+ muted: '#6B7280',
13
+ /** The warm chip the dashboard uses for "Available on". */
14
+ chipBg: '#FFF1E7',
15
+ chipInk: '#8A4B1A',
16
+ pill: '#14171A',
17
+ pillInk: '#FFFFFF',
18
+ live: '#22C55E',
19
+ warnBg: '#FFF6E5',
20
+ warnInk: '#8A5A08',
21
+ danger: '#C5221F',
22
+ };
23
+
24
+ export const type = {
25
+ hero: { fontSize: 28, fontWeight: '700' as const, color: palette.ink },
26
+ section: { fontSize: 18, fontWeight: '700' as const, color: palette.ink },
27
+ rowTitle: { fontSize: 17, fontWeight: '600' as const, color: palette.ink },
28
+ meta: { fontSize: 14, color: palette.muted },
29
+ label: { fontSize: 15, color: palette.muted },
30
+ chip: { fontSize: 14, fontWeight: '500' as const, color: palette.chipInk },
31
+ pill: { fontSize: 16, fontWeight: '600' as const, color: palette.pillInk },
32
+ };
33
+
34
+ export const radius = { card: 16, chip: 8, pill: 999 };
35
+ export const space = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 };
36
+
37
+ export const cardShadow = {
38
+ shadowColor: '#0B1220',
39
+ shadowOpacity: 0.05,
40
+ shadowRadius: 10,
41
+ shadowOffset: { width: 0, height: 2 },
42
+ elevation: 1,
43
+ };