@simon-says-build/demo-player 0.11.0 → 0.12.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/package.json +1 -1
- package/src/DemoHost.js +38 -10
- package/src/build-gate.js +62 -0
- package/src/core.js +1 -1
- package/src/signin.js +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "SimonSays mobile demo player: walks a preview's viewer through a job's change, inside the app. Renders nothing unless the bundle carries a demo.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/DemoHost.js
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
//
|
|
14
14
|
// No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
|
|
15
15
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
-
import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
16
|
+
import { Linking, NativeModules, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
17
17
|
import { pageName } from './page-name.js';
|
|
18
18
|
import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, introBody, introOf, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
|
|
19
19
|
import { demoApi, tourOf } from './api.js';
|
|
20
20
|
import { signInFirst, openInApp } from './signin.js';
|
|
21
21
|
import { runEvent, fillStep } from './events.js';
|
|
22
|
+
import { nativeRuntime, buildGap, needLinks, openNeed, staleBody } from './build-gate.js';
|
|
22
23
|
|
|
23
24
|
const h = React.createElement;
|
|
24
25
|
const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
|
|
@@ -83,11 +84,25 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
83
84
|
const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
|
|
84
85
|
const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
|
|
85
86
|
const asked = useRef(new Set()); // steps already rescued once (by index)
|
|
87
|
+
const [stale, setStale] = useState(null); // { gap, links, appName }: this dev build is not the review's (build-gate.js)
|
|
86
88
|
|
|
87
89
|
useEffect(() => {
|
|
88
90
|
let live = true;
|
|
89
91
|
api.load()
|
|
90
|
-
.then((d) => {
|
|
92
|
+
.then((d) => {
|
|
93
|
+
if (!live) return;
|
|
94
|
+
// THE BUILD FIRST (0.12.0): a review made for another native build of
|
|
95
|
+
// this dev app never starts here — the person is sent to install the
|
|
96
|
+
// right one (build-gate.js). Nothing else runs: no sign-in, no steps.
|
|
97
|
+
const gap = config.here ? null : buildGap(d, nativeRuntime(globalThis.expo?.modules, NativeModules));
|
|
98
|
+
if (gap) {
|
|
99
|
+
const links = needLinks({ demo: d, gap, demoId: config.id, token: config.token });
|
|
100
|
+
setStale({ gap, links, appName: d.projectName || d.appName || 'the app' });
|
|
101
|
+
openNeed(links, (u) => Linking.openURL(u));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d));
|
|
105
|
+
})
|
|
91
106
|
.catch((e) => { if (live) setError(e.message); });
|
|
92
107
|
return () => { live = false; };
|
|
93
108
|
}, [api]);
|
|
@@ -184,7 +199,8 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
184
199
|
|
|
185
200
|
// AND NOW… (an `event` step, 0.8.0): SSB runs the helper on the copy; its
|
|
186
201
|
// outputs fill `{name}` in this person's later steps. A failure stays on the
|
|
187
|
-
// card, said, with
|
|
202
|
+
// card, said, with Try again (0.12.0: never Next — what follows depends on it).
|
|
203
|
+
const [eventTry, setEventTry] = useState(0);
|
|
188
204
|
useEffect(() => {
|
|
189
205
|
setEvent(null);
|
|
190
206
|
if (step?.kind !== 'event' || done) return undefined;
|
|
@@ -197,11 +213,13 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
197
213
|
else setIndex(index + 1);
|
|
198
214
|
});
|
|
199
215
|
return () => { live = false; };
|
|
200
|
-
}, [step?.kind, step?.helper, index, done]);
|
|
216
|
+
}, [step?.kind, step?.helper, index, done, eventTry]);
|
|
201
217
|
|
|
202
218
|
// SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
|
|
203
219
|
// the app sign in and route, then start their part on that screen. A link
|
|
204
|
-
// that can't be had or opened
|
|
220
|
+
// that can't be had or opened, or a sign-in whose first screen never comes
|
|
221
|
+
// up, stops the tour with Try again (0.12.0).
|
|
222
|
+
const [signInTry, setSignInTry] = useState(0); // "Try again" re-runs the sign-in
|
|
205
223
|
useEffect(() => {
|
|
206
224
|
setSignIn(null);
|
|
207
225
|
if (step?.kind !== 'signin' || done) return undefined;
|
|
@@ -217,10 +235,16 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
217
235
|
else setIndex(index + 1);
|
|
218
236
|
});
|
|
219
237
|
return () => { live = false; };
|
|
220
|
-
}, [step, done]);
|
|
238
|
+
}, [step, done, signInTry]);
|
|
221
239
|
|
|
222
240
|
const overlay = (() => {
|
|
223
241
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
242
|
+
if (stale) {
|
|
243
|
+
return h(Card, {
|
|
244
|
+
at: null, screen, insets, card, setCard, title: 'Guided Review', body: staleBody(stale.gap, stale.appName),
|
|
245
|
+
actions: [{ label: 'Open SimonSays', onPress: () => openNeed(stale.links, (u) => Linking.openURL(u)), primary: true }],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
224
248
|
if (!steps) return null;
|
|
225
249
|
if (intro && !tucked && !done) {
|
|
226
250
|
return h(Card, {
|
|
@@ -241,18 +265,22 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
241
265
|
}
|
|
242
266
|
if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
243
267
|
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
268
|
+
// A FAILED SETUP OR SIGN-IN STOPS THE TOUR (0.12.0, Michael 2026-11-05:
|
|
269
|
+
// "the tour is theoretically continuable… we need a way to make the tour
|
|
270
|
+
// aware that an important step failed"). What follows depends on it, so
|
|
271
|
+
// there is no Next: Try again, or Hide.
|
|
244
272
|
if (step.kind === 'event') {
|
|
245
273
|
return h(Card, {
|
|
246
274
|
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
247
|
-
body: event ? `The
|
|
248
|
-
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
275
|
+
body: event ? `The review couldn't set this up, and the rest of it depends on it: ${event.error}` : step.caption,
|
|
276
|
+
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setEventTry((n) => n + 1), primary: true }] : [],
|
|
249
277
|
});
|
|
250
278
|
}
|
|
251
279
|
if (step.kind === 'signin') {
|
|
252
280
|
return h(Card, {
|
|
253
281
|
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
254
|
-
body: signIn ?
|
|
255
|
-
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
282
|
+
body: signIn ? `SSB couldn't sign you in, and the review needs it: ${signIn.error}.` : step.caption,
|
|
283
|
+
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setSignInTry((n) => n + 1), primary: true }] : [],
|
|
256
284
|
});
|
|
257
285
|
}
|
|
258
286
|
const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// THE DEV BUILD MUST MATCH THE REVIEW (0.12.0, docs/TOUR-LINKS.md "6. The
|
|
2
|
+
// bundle checks itself"). Pure, so node --test runs it.
|
|
3
|
+
//
|
|
4
|
+
// A phone review's copy is Metro serving the session's JS, made for ONE native
|
|
5
|
+
// build of the app's dev client: `demo.preview.fingerprint`, the build SSB put
|
|
6
|
+
// in the library for it. A dev client loads whatever JS it is pointed at, so
|
|
7
|
+
// on an older build the review's code ran anyway and failed half-way: Michael's
|
|
8
|
+
// phone (2026-11-05) opened Connie Ho's Profile photos review on a Walk2Gether
|
|
9
|
+
// Dev build older than c316 and its sign-in died "auth/network-request-failed".
|
|
10
|
+
// Every check before that point (the notification's install step, the
|
|
11
|
+
// overlay's ssb-tour gate) was skipped — no install record, a dev-client link.
|
|
12
|
+
//
|
|
13
|
+
// The structural answer is the bundle itself: the player knows what native
|
|
14
|
+
// build the review needs (the demo record) and what native build it is running
|
|
15
|
+
// on (expo-updates' runtimeVersion — under the fingerprint policy, the native
|
|
16
|
+
// fingerprint; the dev builds read it from EXUpdates.bundle/fingerprint). A
|
|
17
|
+
// mismatch never starts the review: it hands the person to the SimonSays app's
|
|
18
|
+
// "need" screen (`simonsays://need?…`, which offers the build SSB made), and
|
|
19
|
+
// the website's /need page when the app is not there. A build that cannot say
|
|
20
|
+
// its runtime, or a review that names none, plays as before.
|
|
21
|
+
|
|
22
|
+
/** This build's native runtime (expo-updates' runtimeVersion), or null. */
|
|
23
|
+
export function nativeRuntime(mods = globalThis.expo?.modules, nativeModules = {}) {
|
|
24
|
+
const u = mods?.ExpoUpdates || nativeModules?.ExpoUpdates || {};
|
|
25
|
+
return typeof u.runtimeVersion === 'string' && u.runtimeVersion && !u.runtimeVersion.startsWith('file:') ? u.runtimeVersion : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the review needs that this build is not: null = play it, else { need, have }. */
|
|
29
|
+
export function buildGap(demo, have) {
|
|
30
|
+
const need = demo?.platform === 'ios' && typeof demo?.preview?.fingerprint === 'string' ? demo.preview.fingerprint : null;
|
|
31
|
+
if (!need || !have) return null;
|
|
32
|
+
return need === have ? null : { need, have };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The dev app's scheme, from the link the copy was opened with
|
|
36
|
+
// (`walk2gether-dev://expo-development-client/?url=…`).
|
|
37
|
+
const schemeOf = (url) => /^([a-z][a-z0-9+.-]*):\/\//i.exec(String(url || ''))?.[1] || undefined;
|
|
38
|
+
const qs = (o) => Object.entries(o).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where to send the person: the SimonSays app's need screen, else the site's.
|
|
42
|
+
* The query is schemas/tour-link.mjs needUrl's (test/build-gate.test.mjs holds
|
|
43
|
+
* them equal): app, scheme, tour, rt, haveRt.
|
|
44
|
+
*/
|
|
45
|
+
export function needLinks({ demo = {}, gap, demoId, token, site = 'https://simonsays.build' }) {
|
|
46
|
+
const q = qs({
|
|
47
|
+
app: demo.appId, scheme: schemeOf(demo.preview?.url), tour: demoId && token ? `${demoId}.${token}` : undefined,
|
|
48
|
+
rt: gap?.need, haveRt: gap?.have || undefined,
|
|
49
|
+
});
|
|
50
|
+
return { app: `simonsays://need?${q}`, web: `${site.replace(/\/$/, '')}/need?${q}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Open the app's need screen, falling through to the site. `openURL` is Linking.openURL. */
|
|
54
|
+
export async function openNeed(links, openURL) {
|
|
55
|
+
try { await openURL(links.app); return 'app'; } catch { /* no SimonSays app */ }
|
|
56
|
+
try { await openURL(links.web); return 'web'; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The card's words. */
|
|
60
|
+
export function staleBody(gap, appName = 'the app') {
|
|
61
|
+
return `This review needs a newer ${appName} Dev build (it needs ${gap.need.slice(0, 7)}; this phone has ${gap.have.slice(0, 7)}). Opening the SimonSays app to install it.`;
|
|
62
|
+
}
|
package/src/core.js
CHANGED
|
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
|
|
|
10
10
|
// What this player is, said when the app registers for "Ready to see"
|
|
11
11
|
// (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
|
|
12
12
|
// equal to package.json by test/core.test.mjs.
|
|
13
|
-
export const PLAYER_VERSION = '0.
|
|
13
|
+
export const PLAYER_VERSION = '0.12.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
package/src/signin.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// only then starts the tour, on that screen. Pure, so node --test runs it.
|
|
7
7
|
|
|
8
8
|
const SETTLE_MIN_MS = 1500; // the app's handler signs in and routes before anything is looked for
|
|
9
|
-
const SETTLE_MAX_MS = 12000; // then the
|
|
9
|
+
const SETTLE_MAX_MS = 12000; // and then the sign-in has not taken: said, the tour stops (0.12.0)
|
|
10
10
|
const POLL_MS = 250;
|
|
11
11
|
|
|
12
12
|
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -38,8 +38,8 @@ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform
|
|
|
38
38
|
/**
|
|
39
39
|
* Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
|
|
40
40
|
* Linking.openURL; `settled()` says the first screen is up (its element is
|
|
41
|
-
* found). → { ok: true } | { ok: false, error } (the player
|
|
42
|
-
*
|
|
41
|
+
* found). → { ok: true } | { ok: false, error } (the player stops the tour
|
|
42
|
+
* with Try again).
|
|
43
43
|
*/
|
|
44
44
|
export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
|
|
45
45
|
let link;
|
|
@@ -51,6 +51,12 @@ export async function signInFirst({ api, actorIndex, openURL, settled = () => tr
|
|
|
51
51
|
return { ok: false, error: e?.message || String(e) };
|
|
52
52
|
}
|
|
53
53
|
await sleep(minMs);
|
|
54
|
-
|
|
54
|
+
let ok = false;
|
|
55
|
+
for (let waited = minMs; waited < maxMs; waited += POLL_MS) { if ((ok = !!settled())) break; await sleep(POLL_MS); }
|
|
56
|
+
// CONFIRMED, NOT ASSUMED (0.12.0): the first screen past sign-in never came
|
|
57
|
+
// up, so the sign-in did not take — the app's own handler failed (2026-11-05:
|
|
58
|
+
// "Failed to verify token: [auth/network-request-failed]" while the tour
|
|
59
|
+
// carried on to Next and Take me there). Said, and the tour stops.
|
|
60
|
+
if (!ok) return { ok: false, error: "the app didn't sign in (its first screen never appeared)" };
|
|
55
61
|
return { ok: true };
|
|
56
62
|
}
|