@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 +102 -0
- package/package.json +37 -0
- package/src/BranchIcon.tsx +57 -0
- package/src/ControlCenter.tsx +521 -0
- package/src/XpremMark.tsx +58 -0
- package/src/config.ts +113 -0
- package/src/index.ts +2 -0
- package/src/surf.ts +152 -0
- package/src/theme.ts +43 -0
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’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
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
|
+
};
|