@simon-says-build/ssb-overlay 0.4.0 → 0.6.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/index.d.ts CHANGED
@@ -41,5 +41,11 @@ export function SsbHost(props: {
41
41
  voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
42
42
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
43
43
  insets?: { top?: number; bottom?: number };
44
+ /**
45
+ * Which build this is: the origin the app's JS bundle was loaded from (a
46
+ * SimonSays preview's tunnel). Read off the bundle when omitted; Ready lists
47
+ * only what this build holds, and ▶ Demo plays it here.
48
+ */
49
+ bundle?: string;
44
50
  children?: ReactNode;
45
51
  }): ReactNode;
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <id> [--no-install] [--json]
3
+ // (install/install.mjs; docs/IN-APP-SSB.md "Installing it in an app")
4
+ import path from 'node:path';
5
+ import { install } from './install.mjs';
6
+
7
+ const args = process.argv.slice(2);
8
+ const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : undefined; };
9
+ if (args[0] !== 'install') {
10
+ console.error('usage: ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]');
11
+ process.exit(64);
12
+ }
13
+ const dir = path.resolve(args[1] && !args[1].startsWith('--') ? args[1] : '.');
14
+ try {
15
+ const r = install(dir, { appId: opt('--app-id'), npmInstall: !args.includes('--no-install') });
16
+ if (args.includes('--json')) console.log(JSON.stringify(r, null, 2));
17
+ else {
18
+ for (const d of r.done) console.log(`✓ ${d}`);
19
+ for (const t of r.todo) console.log(`! ${t}`);
20
+ console.log(`SSB settings for this app (inAppSsb): ${JSON.stringify(r.settings)}`);
21
+ }
22
+ process.exit(0);
23
+ } catch (e) {
24
+ console.error(`ssb-overlay install: ${e.message}`);
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,343 @@
1
+ // INSTALL SSB IN AN APP (docs/IN-APP-SSB.md "Installing it in an app",
2
+ // 2026-10-20, the product owner: "we just need SSB to reliably be able to
3
+ // install the package").
4
+ //
5
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]
6
+ //
7
+ // Reads the app's facts off its repo, adds the packages, writes ONE component
8
+ // (`SsbOverlay`) wired to what the app already has, wraps the root layout in
9
+ // it, and says what SSB should record about the app (`inAppSsb`). Anything it
10
+ // cannot do safely it does NOT guess: it says so by name (`todo[]`), so the
11
+ // job that ran it — or a person — finishes that part. Never throws on a fact
12
+ // it cannot find; exits non-zero only when it changed nothing it was asked to.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execFileSync } from 'node:child_process';
16
+
17
+ const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
18
+ const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
19
+ const exists = (p) => fs.existsSync(p);
20
+
21
+ /** The packages an install adds (react-native-view-shot through `expo install`, so its version fits the SDK). */
22
+ export const PACKAGES = ['@simon-says-build/ssb-overlay', '@simon-says-build/demo-player', 'react-native-view-shot'];
23
+
24
+ // ---- what the app is ----------------------------------------------------------
25
+ /** Every fact the install decides by, read off the repo. Pure apart from reading files. */
26
+ export function detect(dir) {
27
+ const pkg = json(path.join(dir, 'package.json')) || {};
28
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
29
+ const has = (n) => Object.prototype.hasOwnProperty.call(deps, n);
30
+ const ts = exists(path.join(dir, 'tsconfig.json'));
31
+ const layout = ['app/_layout.tsx', 'app/_layout.jsx', 'app/_layout.js', 'src/app/_layout.tsx', 'src/app/_layout.js']
32
+ .map((p) => path.join(dir, p)).find(exists) || null;
33
+
34
+ // How people sign in: the app's own backend, which SSB checks their token against.
35
+ let auth = null, backend = null, firebaseProject = null, supabaseClient = null;
36
+ if (has('@react-native-firebase/auth')) auth = 'rn-firebase';
37
+ else if (has('firebase')) auth = 'firebase-js';
38
+ else if (has('@supabase/supabase-js')) auth = 'supabase';
39
+ if (auth === 'rn-firebase' || auth === 'firebase-js') {
40
+ firebaseProject = firebaseProjectOf(dir);
41
+ backend = firebaseProject ? `firebase:${firebaseProject}` : null;
42
+ }
43
+ if (auth === 'supabase') {
44
+ supabaseClient = supabaseClientOf(dir);
45
+ backend = 'process.env.EXPO_PUBLIC_SUPABASE_URL';
46
+ }
47
+ // The claim the app's own code marks its admins with (`claims.admin`, `admin?: boolean`).
48
+ const adminClaim = auth && auth !== 'supabase' && grepSource(dir, /\bclaims\??\.admin\b|\badmin\?\s*:\s*boolean/) ? 'admin' : null;
49
+
50
+ return {
51
+ dir, ts, layout,
52
+ expo: deps.expo || null,
53
+ router: has('expo-router'),
54
+ auth, backend, firebaseProject, supabaseClient, adminClaim,
55
+ voice: has('livekit-client') && has('@livekit/react-native'),
56
+ notifications: has('expo-notifications'),
57
+ asyncStorage: has('@react-native-async-storage/async-storage'),
58
+ constants: has('expo-constants'),
59
+ // The app already plays SSB's demos itself (Impulse wraps its root in <DemoHost>): keep its own.
60
+ demoHost: !!(layout && /<DemoHost[\s>]/.test(read(layout) || '')),
61
+ installed: PACKAGES.filter(has),
62
+ componentsDir: ['components', 'src/components'].map((p) => path.join(dir, p)).find(exists) || path.join(dir, 'components'),
63
+ };
64
+ }
65
+
66
+ export function firebaseProjectOf(dir) {
67
+ for (const p of ['GoogleService-Info.plist', 'ios/GoogleService-Info.plist']) {
68
+ const t = read(path.join(dir, p));
69
+ const m = t && /<key>PROJECT_ID<\/key>\s*<string>([^<]+)<\/string>/.exec(t);
70
+ if (m) return m[1].trim();
71
+ }
72
+ for (const p of ['google-services.json', 'android/app/google-services.json']) {
73
+ const j = json(path.join(dir, p));
74
+ if (j?.project_info?.project_id) return j.project_info.project_id;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** The module that makes the app's Supabase client (`createClient(` … `export const supabase`), as an import path from `dir`. */
80
+ export function supabaseClientOf(dir) {
81
+ for (const f of sourceFiles(dir)) {
82
+ const t = read(f) || '';
83
+ if (/createClient\s*\(/.test(t) && /export\s+(const|let)\s+supabase\b/.test(t)) return f;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function sourceFiles(dir) {
89
+ const out = [];
90
+ const walk = (d, depth) => {
91
+ if (depth > 6) return;
92
+ let es = [];
93
+ try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
94
+ for (const e of es) {
95
+ if (['node_modules', '.git', 'ios', 'android', '.expo', 'dist', 'build'].includes(e.name)) continue;
96
+ const p = path.join(d, e.name);
97
+ if (e.isDirectory()) walk(p, depth + 1);
98
+ else if (/\.(tsx?|jsx?|mjs)$/.test(e.name)) out.push(p);
99
+ }
100
+ };
101
+ walk(dir, 0);
102
+ return out;
103
+ }
104
+ const grepSource = (dir, re) => sourceFiles(dir).some((f) => re.test(read(f) || ''));
105
+
106
+ // ---- the component ------------------------------------------------------------
107
+ /** The relative import of `to` from the folder of `from`, without extension. */
108
+ export function importPath(from, to) {
109
+ let r = path.relative(path.dirname(from), to).replace(/\\/g, '/').replace(/\.(tsx?|jsx?|mjs)$/, '');
110
+ if (!r.startsWith('.')) r = `./${r}`;
111
+ return r;
112
+ }
113
+
114
+ /**
115
+ * `SsbOverlay`, written for THIS app: its backend and how its session is read,
116
+ * its LiveKit for 🎙 Talk, its expo-notifications for "ready to see", its
117
+ * storage. Returns { file, source }.
118
+ */
119
+ export function component(facts, { appId }) {
120
+ const ext = facts.ts ? 'tsx' : 'js';
121
+ const file = path.join(facts.componentsDir, `SsbOverlay.${ext}`);
122
+ const T = facts.ts;
123
+ const lines = [];
124
+ const imp = [];
125
+ imp.push(`import { SsbHost } from "@simon-says-build/ssb-overlay";`);
126
+ if (!facts.demoHost) imp.push(`import { DemoHost } from "@simon-says-build/demo-player";`);
127
+ imp.push(facts.demoHost ? `import { usePathname } from "expo-router";` : `import { router, usePathname } from "expo-router";`);
128
+ imp.push(`import ${T ? 'React, { type ReactNode, useEffect, useState }' : 'React, { useEffect, useState }'} from "react";`);
129
+ imp.push(`import { captureScreen } from "react-native-view-shot";`);
130
+ if (facts.asyncStorage) imp.push(`import AsyncStorage from "@react-native-async-storage/async-storage";`);
131
+ if (facts.notifications) imp.push(`import * as Notifications from "expo-notifications";`);
132
+ if (facts.notifications && facts.constants) imp.push(`import Constants from "expo-constants";`);
133
+ if (facts.voice) { imp.push(`import { AudioSession } from "@livekit/react-native";`); imp.push(`import { Room } from "livekit-client";`); }
134
+
135
+ // How the overlay reads the session — the app's own sign-in, never a new one.
136
+ let session;
137
+ if (facts.auth === 'rn-firebase') {
138
+ imp.push(`import { getAuth, onAuthStateChanged } from "@react-native-firebase/auth";`);
139
+ session = [
140
+ `// The signed-in user, from the app's own Firebase sign-in.`,
141
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
142
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
143
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
144
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
145
+ `}`,
146
+ ];
147
+ } else if (facts.auth === 'firebase-js') {
148
+ imp.push(`import { getAuth, onAuthStateChanged } from "firebase/auth";`);
149
+ session = [
150
+ `// The signed-in user, from the app's own Firebase sign-in.`,
151
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
152
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
153
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
154
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
155
+ `}`,
156
+ ];
157
+ } else if (facts.auth === 'supabase' && facts.supabaseClient) {
158
+ imp.push(`import { supabase } from "${importPath(file, facts.supabaseClient)}";`);
159
+ session = [
160
+ `// The signed-in user, from the app's own Supabase sign-in.`,
161
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
162
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(null);`,
163
+ ` useEffect(() => {`,
164
+ ` if (!supabase) return undefined;`,
165
+ ` supabase.auth.getSession().then(({ data }) => setUid(data.session?.user.id ?? null));`,
166
+ ` const { data } = supabase.auth.onAuthStateChange((_e, s) => setUid(s?.user.id ?? null));`,
167
+ ` return () => data.subscription.unsubscribe();`,
168
+ ` }, []);`,
169
+ ` return { uid, token: async () => (supabase ? (await supabase.auth.getSession()).data.session?.access_token ?? null : null) };`,
170
+ `}`,
171
+ ];
172
+ } else {
173
+ session = [
174
+ `// TODO(ssb): the app's sign-in could not be read off the repo. Return the`,
175
+ `// signed-in user's id and their session token from the app's own auth.`,
176
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
177
+ ` return { uid: null, token: async () => null };`,
178
+ `}`,
179
+ ];
180
+ }
181
+
182
+ const backend = facts.backend?.startsWith('process.env.') ? facts.backend : JSON.stringify(facts.backend || '');
183
+ lines.push(
184
+ `// SimonSays inside the app (written by \`npx @simon-says-build/ssb-overlay install\`;`,
185
+ `// docs: https://github.com/simon-says-studio/simon-says-build/blob/main/docs/IN-APP-SSB.md).`,
186
+ `//`,
187
+ `// <SsbHost> draws SSB's button ONLY for a login SimonSays lets in — checked by`,
188
+ `// SimonSays against this app's own backend with the user's own session token;`,
189
+ ...(facts.demoHost ? [`// everyone else sees nothing. The app's own demo player plays SSB's demos.`] : [
190
+ `// everyone else sees nothing. <DemoHost> plays a change's demo, and renders the`,
191
+ `// app unchanged in any build that does not carry one.`,
192
+ ]),
193
+ ...imp, ``,
194
+ `// This app in SimonSays, and where its people sign in.`,
195
+ `const SSB_APP_ID = ${JSON.stringify(appId)};`,
196
+ `const SSB_BACKEND = ${backend};`, ``,
197
+ );
198
+ if (facts.voice) {
199
+ lines.push(
200
+ `// 🎙 Talk, on the app's own LiveKit. If the app manages its audio session itself`,
201
+ `// (calls, recording), start and stop it here through that code instead.`,
202
+ `const SSB_VOICE = {`,
203
+ ` Room,`,
204
+ ` startAudio: () => AudioSession.startAudioSession(),`,
205
+ ` stopAudio: () => AudioSession.stopAudioSession(),`,
206
+ `};`, ``,
207
+ );
208
+ }
209
+ if (facts.notifications) {
210
+ lines.push(
211
+ `// The device, for "it's done, tap to see it" — asked only once SimonSays lets the`,
212
+ `// login in, and only when the app already has notification permission.`,
213
+ `async function pushToken() {`,
214
+ ` ${facts.constants ? 'const projectId = Constants.expoConfig?.extra?.eas?.projectId;' : 'const projectId = undefined;'}`,
215
+ ` if (!projectId) return null;`,
216
+ ` const { status } = await Notifications.getPermissionsAsync();`,
217
+ ` if (status !== "granted") return null;`,
218
+ ` return (await Notifications.getExpoPushTokenAsync({ projectId })).data;`,
219
+ `}`, ``,
220
+ );
221
+ }
222
+ if (!facts.demoHost) lines.push(
223
+ `// The demo player opens a step's in-app path by plain string.`,
224
+ `const DEMO_ROUTER = { push: (path${T ? ': string' : ''}) => router.push(path${T ? ' as never' : ''}) };`, ``,
225
+ );
226
+ lines.push(
227
+ ...session, ``,
228
+ `export function SsbOverlay({ children }${T ? ': { children: ReactNode }' : ''}) {`,
229
+ ` const session = useSession();`,
230
+ ` const route = usePathname();`,
231
+ ` return (`,
232
+ ` <SsbHost`,
233
+ ` appId={SSB_APP_ID}`,
234
+ ` backend={SSB_BACKEND}`,
235
+ ` getToken={session.token}`,
236
+ ` sessionKey={session.uid}`,
237
+ ` route={route}`,
238
+ ` capture={() => captureScreen({ format: "jpg", quality: 0.6, result: "data-uri" })}`,
239
+ ...(facts.asyncStorage ? [` storage={AsyncStorage}`] : []),
240
+ ...(facts.notifications ? [` pushToken={pushToken}`, ` notifications={Notifications}`] : []),
241
+ ...(facts.voice ? [` voice={SSB_VOICE}`] : []),
242
+ ` >`,
243
+ ...(facts.demoHost ? [` {children}`] : [
244
+ ` <DemoHost`,
245
+ ` router={DEMO_ROUTER}`,
246
+ ` demo={{`,
247
+ ` id: process.env.EXPO_PUBLIC_SSB_DEMO_ID,`,
248
+ ` token: process.env.EXPO_PUBLIC_SSB_DEMO_TOKEN,`,
249
+ ` api: process.env.EXPO_PUBLIC_SSB_API,`,
250
+ ` }}`,
251
+ ` >`,
252
+ ` {children}`,
253
+ ` </DemoHost>`,
254
+ ]),
255
+ ` </SsbHost>`,
256
+ ` );`,
257
+ `}`, ``,
258
+ );
259
+ return { file, source: lines.join('\n') };
260
+ }
261
+
262
+ // ---- the root layout ------------------------------------------------------------
263
+ /**
264
+ * The layout with its default export's returned JSX wrapped in <SsbOverlay>,
265
+ * or { error } when its shape is not the one this can wrap safely: an
266
+ * `export default function` whose body returns `( … )`.
267
+ */
268
+ export function wrapLayout(source, importFrom) {
269
+ if (/<SsbOverlay[\s>]/.test(source)) return { source, already: true };
270
+ const fn = /export\s+default\s+function\s+[A-Za-z0-9_]*\s*\([^)]*\)\s*(:\s*[^{]+)?\{/.exec(source);
271
+ if (!fn) return { error: 'the root layout does not `export default function` — wrap its returned JSX in <SsbOverlay> by hand' };
272
+ // The function body, by brace depth (strings and comments are rare enough in a layout's braces to accept).
273
+ const bodyStart = fn.index + fn[0].length;
274
+ let depth = 1, i = bodyStart;
275
+ for (; i < source.length && depth > 0; i++) {
276
+ if (source[i] === '{') depth++;
277
+ else if (source[i] === '}') depth--;
278
+ }
279
+ const bodyEnd = i - 1;
280
+ // The LAST `return (` at the body's own depth: the layout's main render.
281
+ let d = 0, ret = -1;
282
+ for (let j = bodyStart; j < bodyEnd; j++) {
283
+ const c = source[j];
284
+ if (c === '{') d++;
285
+ else if (c === '}') d--;
286
+ else if (d === 0 && source.startsWith('return', j) && /^return\s*\(/.test(source.slice(j, j + 20))) ret = j;
287
+ }
288
+ if (ret < 0) return { error: 'the root layout does not `return ( … )` at its top level — wrap its returned JSX in <SsbOverlay> by hand' };
289
+ const open = source.indexOf('(', ret);
290
+ let p = 1, k = open + 1;
291
+ for (; k < bodyEnd && p > 0; k++) {
292
+ if (source[k] === '(') p++;
293
+ else if (source[k] === ')') p--;
294
+ }
295
+ const close = k - 1;
296
+ const inner = source.slice(open + 1, close);
297
+ if (!inner.trim().startsWith('<')) return { error: 'the root layout returns something other than JSX — wrap it in <SsbOverlay> by hand' };
298
+ const wrapped = `${source.slice(0, open + 1)}\n <SsbOverlay>${inner.replace(/\s+$/, '')}\n </SsbOverlay>\n ${source.slice(close)}`;
299
+ // The import, after the last import line.
300
+ const imports = [...wrapped.matchAll(/^import[\s\S]*?from\s+["'][^"']+["'];?[ \t]*$/gm)];
301
+ const at = imports.length ? imports.at(-1).index + imports.at(-1)[0].length : 0;
302
+ const line = `\nimport { SsbOverlay } from "${importFrom}";`;
303
+ return { source: wrapped.slice(0, at) + line + wrapped.slice(at) };
304
+ }
305
+
306
+ // ---- what SSB records about the app ------------------------------------------------
307
+ export function settingsFor(facts) {
308
+ const s = {};
309
+ if (facts.firebaseProject) s.firebaseProjects = [facts.firebaseProject];
310
+ if (facts.adminClaim) s.adminClaims = [facts.adminClaim];
311
+ return s;
312
+ }
313
+
314
+ // ---- the install ------------------------------------------------------------------
315
+ export function install(dir, { appId, npmInstall = true, run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: 'inherit' }) } = {}) {
316
+ if (!appId) throw new Error('--app-id is required (the app\'s id in SimonSays)');
317
+ const facts = detect(dir);
318
+ const done = [], todo = [];
319
+ if (!facts.expo) todo.push('this is not an Expo app (no `expo` dependency) — the overlay is for Expo apps');
320
+ if (!facts.router) todo.push('no expo-router: pass the current route to <SsbHost route> from the app\'s own navigation');
321
+ if (!facts.backend) todo.push(facts.auth ? `could not find the ${facts.auth === 'supabase' ? 'Supabase URL' : 'Firebase project id (GoogleService-Info.plist / google-services.json)'} — set SSB_BACKEND by hand` : 'could not tell how the app signs in — fill in useSession() and SSB_BACKEND by hand');
322
+ if (facts.auth === 'supabase' && !facts.supabaseClient) todo.push('could not find the module exporting the Supabase client — fill in useSession() by hand');
323
+
324
+ if (npmInstall) {
325
+ run('npx', ['--yes', 'expo', 'install', ...PACKAGES], dir);
326
+ done.push(`added ${PACKAGES.join(', ')}`);
327
+ }
328
+
329
+ const { file, source } = component(facts, { appId });
330
+ fs.mkdirSync(path.dirname(file), { recursive: true });
331
+ fs.writeFileSync(file, source);
332
+ done.push(`wrote ${path.relative(dir, file)}`);
333
+
334
+ if (facts.layout) {
335
+ const r = wrapLayout(fs.readFileSync(facts.layout, 'utf8'), importPath(facts.layout, file));
336
+ if (r.error) todo.push(`${path.relative(dir, facts.layout)}: ${r.error}`);
337
+ else if (r.already) done.push(`${path.relative(dir, facts.layout)} already wraps <SsbOverlay>`);
338
+ else { fs.writeFileSync(facts.layout, r.source); done.push(`wrapped ${path.relative(dir, facts.layout)} in <SsbOverlay>`); }
339
+ } else todo.push('no root layout (app/_layout) — wrap the app\'s root component in <SsbOverlay> by hand');
340
+
341
+ if (facts.voice) todo.push('🎙 Talk uses @livekit/react-native\'s AudioSession; if the app manages its own audio session (calls, recording), route SSB_VOICE.startAudio/stopAudio through that code');
342
+ return { facts: { ...facts, dir: undefined }, settings: settingsFor(facts), done, todo };
343
+ }
package/package.json CHANGED
@@ -1,17 +1,33 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
8
+ "directory": "packages/ssb-overlay"
9
+ },
5
10
  "type": "module",
6
11
  "main": "index.js",
7
12
  "types": "index.d.ts",
8
- "files": ["index.js", "index.d.ts", "src/", "assets/", "README.md"],
13
+ "bin": {
14
+ "ssb-overlay": "install/cli.mjs"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "index.d.ts",
19
+ "src/",
20
+ "assets/",
21
+ "install/",
22
+ "README.md"
23
+ ],
9
24
  "peerDependencies": {
25
+ "@simon-says-build/demo-player": ">=0.1.0",
10
26
  "react": ">=18",
11
27
  "react-native": ">=0.76"
12
28
  },
13
29
  "scripts": {
14
- "test": "node --test test/"
30
+ "test": "node --test test/*.test.mjs"
15
31
  },
16
32
  "license": "UNLICENSED"
17
33
  }
package/src/SsbHost.js CHANGED
@@ -21,11 +21,14 @@
21
21
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
22
  import {
23
23
  ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
24
- ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
24
+ NativeModules, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
25
25
  } from 'react-native';
26
+ // The app's demo player (a peer): ▶ Demo hands it the demo to play in this
27
+ // build. An older player has no playDemo, and the button is not offered.
28
+ import { playDemo } from '@simon-says-build/demo-player';
26
29
  import {
27
30
  FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
28
- STATE_WORD, boxItem, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
31
+ STATE_WORD, boxItem, bundleOriginOf, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
29
32
  } from './core.js';
30
33
 
31
34
  const h = React.createElement;
@@ -65,11 +68,16 @@ const frames = (n = 2) => new Promise((resolve) => {
65
68
  step(n);
66
69
  });
67
70
 
68
- export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, children }) {
71
+ // Where this app's JS was loaded from: a preview's tunnel, or a file.
72
+ const scriptURL = () => {
73
+ try { const sc = NativeModules.SourceCode; return sc?.getConstants?.()?.scriptURL ?? sc?.scriptURL ?? ''; } catch { return ''; }
74
+ };
75
+
76
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, children }) {
69
77
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
70
78
  useSsbLinks(notifications);
71
79
  if (!config || typeof getToken !== 'function') return children ?? null;
72
- return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
80
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
73
81
  }
74
82
 
75
83
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -85,7 +93,12 @@ function useSsbLinks(notifications) {
85
93
  }, [notifications]);
86
94
  }
87
95
 
88
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
96
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, children }) {
97
+ // WHICH BUILD THIS IS (docs/IN-APP-SSB.md "Ready is this build"): Ready is
98
+ // only what this bundle holds. `bundle` overrides (the harness); else the
99
+ // origin the JS came from.
100
+ const here = useMemo(() => (bundle !== undefined ? String(bundle || '') : bundleOriginOf(scriptURL())), [bundle]);
101
+ const [elsewhere, setElsewhere] = useState([]);
89
102
  const screen = useWindowDimensions();
90
103
  const tokenRef = useRef(getToken);
91
104
  tokenRef.current = getToken;
@@ -181,7 +194,18 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
181
194
  const open = mode !== 'idle';
182
195
  useEffect(() => { if (open) load(); }, [open, chatId]);
183
196
  // The top of the panel: the app's work, asked when it opens and while it stays open.
184
- const loadWork = useCallback(async () => { try { setWork((await ssb.work())?.inFlight || []); } catch { /* the last list stands */ } }, [ssb]);
197
+ const loadWork = useCallback(async () => {
198
+ try { const r = await ssb.work(here); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
199
+ }, [ssb, here]);
200
+ // ▶ Demo: one demo of everything Ready in this build, played by this build.
201
+ const startDemo = useCallback(async () => {
202
+ setBusy(true); setError(null);
203
+ try {
204
+ const r = await ssb.demo(here);
205
+ if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
206
+ Keyboard.dismiss(); setMode('idle');
207
+ } catch (e) { setError(e.message); } finally { setBusy(false); }
208
+ }, [ssb, here, config.api]);
185
209
  useEffect(() => {
186
210
  if (mode !== 'sheet' || !gate) return undefined;
187
211
  loadWork();
@@ -217,7 +241,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
217
241
 
218
242
  // A demo opens IN the app (or the dev client): SSB steps aside first, or the
219
243
  // full-screen panel would sit on top of what it opened.
220
- const openLink = useCallback((url) => { if (!url) return; Keyboard.dismiss(); setMode('idle'); Linking.openURL(url).catch(() => {}); }, []);
244
+ const openLink = useCallback((url, fallback) => {
245
+ if (!url) return; Keyboard.dismiss(); setMode('idle');
246
+ Linking.openURL(url).catch(() => (fallback ? Linking.openURL(fallback).catch(() => {}) : null));
247
+ }, []);
221
248
  const openRef = useRef(openSheet);
222
249
  openRef.current = openSheet;
223
250
  const close = useCallback(() => {
@@ -442,7 +469,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
442
469
  h(Pressable, { testID: 'ssb-draw-send', onPress: sendDrawing, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, '→'))) : null);
443
470
  }
444
471
  if (mode === 'sheet') return h(Panel, {
445
- gate, chat, work, screen, insets, draft, setDraft, busy, error,
472
+ gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
473
+ onDemo: typeof playDemo === 'function' ? startDemo : null,
446
474
  onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
447
475
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
448
476
  onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
@@ -455,14 +483,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
455
483
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
456
484
  }
457
485
 
458
- function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
486
+ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
459
487
  const scroll = useRef(null);
460
488
  const [open, setOpen] = useState(null); // the draft whose detail is shown
461
489
  const { flying, waiting, sent } = workView({ inFlight: work, chat });
462
490
  const offer = offerOf(chat);
463
491
  const banner = demoBanner(chat);
464
492
  const msgs = chat?.messages || [];
465
- const hasWork = flying.length || waiting.length || sent.length || banner;
493
+ const ready = flying.filter((x) => x.state === 'ready');
494
+ const away = elsewhereLine(elsewhere);
495
+ const hasWork = flying.length || waiting.length || sent.length || banner || away;
466
496
  const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
467
497
  return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
468
498
  h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
@@ -473,23 +503,28 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
473
503
  h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
474
504
  chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
475
505
  h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
476
- talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
477
- h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
478
- h(Text, { style: s.talkText }, talkLine(talk)),
479
- h(Pressable, { onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.talkBtn }, h(Text, { style: s.talkBtnText }, talk.muted ? 'Unmute' : 'Mute')),
480
- h(Pressable, { onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.talkBtn, s.talkEnd] }, h(Text, { style: [s.talkBtnText, { color: '#fff' }] }, 'End'))) : null,
481
506
  // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
482
507
  hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
483
508
  banner ? h(Pressable, {
484
509
  testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
485
510
  accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
486
511
  }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
487
- flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
488
- ...flying.map((x) => h(View, { key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}` },
489
- h(Text, { style: s.workTitle, numberOfLines: 2 }, x.title),
512
+ flying.length ? h(View, { style: s.sectionRow },
513
+ h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
514
+ ready.length && onDemo ? h(Pressable, { testID: 'ssb-demo-ready', onPress: onDemo, disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Demo what is ready in this build (${ready.length})`, style: [s.primary, s.small, busy && s.disabled] },
515
+ h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
516
+ // A row opens its job in the SimonSays app, the console when that app
517
+ // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
518
+ // who can open it there; anyone else's rows stay plain.
519
+ ...flying.map((x) => h(x.links ? Pressable : View, {
520
+ key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
521
+ ...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
522
+ },
523
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
490
524
  showMe(x)
491
525
  ? h(Pressable, { onPress: () => onOpenLink(showMe(x)), accessibilityRole: 'button', accessibilityLabel: `Show me: ${x.title}`, style: [s.primary, s.small] }, h(Text, { style: s.primaryText }, '▶ Show me'))
492
526
  : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
527
+ away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
493
528
  waiting.length ? h(View, { style: s.sectionRow },
494
529
  h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
495
530
  waiting.length > 1 ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, accessibilityRole: 'button', accessibilityLabel: 'Start all', style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, `Start all (${waiting.length})`)) : null) : null,
@@ -513,17 +548,24 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
513
548
  m.id === offer ? h(Pressable, { onPress: onStartAll, disabled: busy, style: [s.primary, { marginTop: 8, alignSelf: 'flex-start' }] }, h(Text, { style: s.primaryText }, 'Confirm and start work')) : null)),
514
549
  chat?.thinking ? h(View, { style: [s.bubble, s.theirs, s.row] }, h(ActivityIndicator, { size: 'small', color: MUTED }), h(Text, { style: [s.theirsText, { marginLeft: 8 }] }, 'SSB is thinking…')) : null),
515
550
  error ? h(Text, { style: s.error }, error) : null,
551
+ // THE COMPOSER, or while talking the voice controls in its place: voice is
552
+ // the other way to say it, so Hang up sits where Send does.
516
553
  h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
517
- h(TextInput, {
554
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
555
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
556
+ h(Text, { style: s.talkText }, talkLine(talk))) : h(TextInput, {
518
557
  testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
519
558
  placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
520
559
  }),
521
560
  h(View, { style: s.row },
522
561
  h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
523
562
  canTalk && !talk ? h(Pressable, { testID: 'ssb-talk', onPress: onTalk, accessibilityRole: 'button', accessibilityLabel: 'Talk to SSB', style: s.chip }, h(Text, { style: s.chipText }, '🎙 Talk')) : null,
563
+ talk ? h(Pressable, { testID: 'ssb-mute', onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.chip }, h(Text, { style: s.chipText }, talk.muted ? 'Unmute' : 'Mute')) : null,
524
564
  h(View, { style: { flex: 1 } }),
525
- h(Pressable, { testID: 'ssb-send', onPress: onSend, disabled: !draft.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.primary, (!draft.trim() || busy) && s.disabled] },
526
- busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
565
+ talk
566
+ ? h(Pressable, { testID: 'ssb-hangup', onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.primary, s.hangup] }, h(Text, { style: s.primaryText }, 'Hang up'))
567
+ : h(Pressable, { testID: 'ssb-send', onPress: onSend, disabled: !draft.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.primary, (!draft.trim() || busy) && s.disabled] },
568
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
527
569
  }
528
570
 
529
571
  const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
@@ -560,12 +602,12 @@ const s = StyleSheet.create({
560
602
  headLogo: { width: 40, height: 40, borderRadius: 20 },
561
603
  title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
562
604
  sub: { fontSize: 12, color: MUTED, marginTop: 2 },
563
- talkBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: 'rgba(47,210,122,0.08)', borderBottomWidth: 1, borderBottomColor: LINE },
605
+ // While talking, in the composer's place.
606
+ talkBar: { minHeight: 44, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 13, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(47,210,122,0.45)', backgroundColor: 'rgba(47,210,122,0.08)' },
564
607
  talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
565
- talkText: { flex: 1, fontSize: 14, fontWeight: '600', color: TEXT },
566
- talkBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED, marginLeft: 6 },
608
+ talkText: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
567
609
  talkEnd: { backgroundColor: RED, borderColor: RED },
568
- talkBtnText: { fontSize: 13, fontWeight: '700', color: TEXT },
610
+ hangup: { backgroundColor: RED, ...glow(RED, 10) },
569
611
  // The work, at the top.
570
612
  work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
571
613
  section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },
package/src/core.js CHANGED
@@ -43,7 +43,10 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
43
43
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
44
  pushToken: (token, platform) => post('/push-token', { token, platform }),
45
45
  voice: (body) => post('/voice', body),
46
- work: () => call('/work'),
46
+ // `bundle`: where this app's JS came from (a preview's tunnel), so SSB
47
+ // scopes Ready to this build; undefined = the whole list, as before.
48
+ work: (bundle) => call(bundle === undefined ? '/work' : `/work?bundle=${encodeURIComponent(bundle)}`),
49
+ demo: (bundle) => post('/demo', { bundle }),
47
50
  };
48
51
  }
49
52
 
@@ -308,3 +311,17 @@ export function workView({ inFlight = [], chat = null } = {}) {
308
311
  const { waiting, sent } = draftsOf(chat);
309
312
  return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
313
  }
314
+
315
+ // READY IS THIS BUILD (2026-10-20, docs/IN-APP-SSB.md): the origin the app's
316
+ // JS bundle was loaded from — a preview's tunnel — or "" for a build that
317
+ // loads from a file (a store or local build, which holds no SSB preview).
318
+ export function bundleOriginOf(scriptURL) {
319
+ const m = /^(https?:\/\/[^/?#]+)/i.exec(String(scriptURL || ''));
320
+ return m ? m[1] : '';
321
+ }
322
+ // What the overlay says about Ready work this build does not hold.
323
+ export function elsewhereLine(readyElsewhere = []) {
324
+ const n = (readyElsewhere || []).reduce((t, x) => t + (Number(x?.count) || 0), 0);
325
+ if (!n) return '';
326
+ return `${n === 1 ? '1 more change is' : `${n} more changes are`} ready in another build — open its preview to see ${n === 1 ? 'it' : 'them'}.`;
327
+ }