bmweb-cli 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.
Files changed (72) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +351 -0
  3. package/dist/bmweb.js +2790 -0
  4. package/package.json +53 -0
  5. package/runtime/core/bestvm/codec.js +285 -0
  6. package/runtime/core/bestvm/environment.js +116 -0
  7. package/runtime/core/bestvm/executor.js +1483 -0
  8. package/runtime/core/bestvm/index.js +52 -0
  9. package/runtime/core/bestvm/machine.js +491 -0
  10. package/runtime/core/bestvm/operands.js +356 -0
  11. package/runtime/core/bestvm/registers.js +152 -0
  12. package/runtime/core/bestvm/write-guard.js +111 -0
  13. package/runtime/core/ipofile/compile.js +364 -0
  14. package/runtime/core/ipofile/decls.js +187 -0
  15. package/runtime/core/ipofile/emit.js +708 -0
  16. package/runtime/core/ipofile/exec.js +164 -0
  17. package/runtime/core/ipofile/lex.js +243 -0
  18. package/runtime/core/ipofile/parse.js +550 -0
  19. package/runtime/core/ipofile/pool.js +404 -0
  20. package/runtime/core/ipofile/walk.js +431 -0
  21. package/runtime/core/ipovm/builtin-helpers.js +182 -0
  22. package/runtime/core/ipovm/builtins-api.js +610 -0
  23. package/runtime/core/ipovm/builtins-screen.js +493 -0
  24. package/runtime/core/ipovm/builtins-table.js +166 -0
  25. package/runtime/core/ipovm/builtins-text.js +166 -0
  26. package/runtime/core/ipovm/emissions.js +138 -0
  27. package/runtime/core/ipovm/hosts.js +191 -0
  28. package/runtime/core/ipovm/operators.js +229 -0
  29. package/runtime/core/ipovm/structures.js +250 -0
  30. package/runtime/core/ipovm/suspensions.js +241 -0
  31. package/runtime/core/ipovm/tape.js +206 -0
  32. package/runtime/core/ipovm/values.js +241 -0
  33. package/runtime/core/ipovm/vm.js +1166 -0
  34. package/runtime/core/translate.js +526 -0
  35. package/runtime/core/webshim/api-router.js +592 -0
  36. package/runtime/core/webshim/bus.js +95 -0
  37. package/runtime/core/webshim/coding.js +82 -0
  38. package/runtime/core/webshim/data-fetch.js +66 -0
  39. package/runtime/core/webshim/exchange.js +288 -0
  40. package/runtime/core/webshim/framing.js +331 -0
  41. package/runtime/core/webshim/install.js +30 -0
  42. package/runtime/core/webshim/job-runner.js +319 -0
  43. package/runtime/core/webshim/native-bus.js +108 -0
  44. package/runtime/core/webshim/timers.js +82 -0
  45. package/runtime/core/webshim/trace.js +205 -0
  46. package/runtime/core/webshim/transport-base.js +128 -0
  47. package/runtime/core/webshim/variant-resolver.js +249 -0
  48. package/runtime/core/webshim/web-serial-bus.js +734 -0
  49. package/runtime/home/bmweb-home.ips +76 -0
  50. package/runtime/home/bmweb.h +26 -0
  51. package/runtime/screens/activations.js +258 -0
  52. package/runtime/screens/garage/diff.js +331 -0
  53. package/runtime/screens/garage/share.js +276 -0
  54. package/runtime/screens/garage/store.js +547 -0
  55. package/runtime/screens/ipo-runtime/cells.js +176 -0
  56. package/runtime/screens/ipo-runtime/dialogs.js +254 -0
  57. package/runtime/screens/ipo-runtime/home.js +358 -0
  58. package/runtime/screens/ipo-runtime/open.js +393 -0
  59. package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
  60. package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
  61. package/runtime/screens/ipo-runtime/print.js +281 -0
  62. package/runtime/screens/ipo-runtime/program.js +1337 -0
  63. package/runtime/screens/ipo-runtime/protocol.js +464 -0
  64. package/runtime/screens/ipo-runtime/script-scan.js +225 -0
  65. package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
  66. package/runtime/screens/ipo-runtime/ui.js +249 -0
  67. package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
  68. package/runtime/screens/ir.js +324 -0
  69. package/runtime/screens/search/data.js +153 -0
  70. package/runtime/screens/search/match.js +285 -0
  71. package/runtime/screens/search/open.js +66 -0
  72. package/runtime/vendor/fflate.min.js +1 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @file What the live .IPO runtime (screens/ipo-runtime/) borrows from the
3
+ * module view: the shipped bytecode per SGBD, INPA's input dialogs, the job
4
+ * names a key's body can send, and the exact-dictionary caption translation
5
+ * (irLabel). The derived renderer that once drew screens from
6
+ * data/inpa-ir/<ECU>.json is gone -- the module view is the running script.
7
+ */
8
+
9
+ /** How many helper levels irItemBodyJobs follows into calluser bodies. */
10
+ const IR_JOB_SCAN_DEPTH = 2;
11
+
12
+ /** How many tokens before an INPAapiJob call its job-name constant may sit. */
13
+ const IR_JOB_LOOKBACK = 8;
14
+
15
+ /** A job name as the scripts write them: upper-case, at least four chars. */
16
+ const IR_JOB_NAME_RE = /^[A-Z][A-Z0-9_]{3,}$/;
17
+
18
+ /** The INPAapiJob / INP1apiJob call names. */
19
+ const IR_JOB_CALL_RE = /^INP.?apiJob/;
20
+
21
+ /**
22
+ * The SGBD whose SCRIPT draws: the configured base when the car identified a
23
+ * variant that ships no .IPO of its own (INPA loads one script per address).
24
+ * @param {object} ecu - the module ({_irFrom, sgbd})
25
+ * @returns {string} lower-case SGBD name
26
+ */
27
+ function irExecSgbd(ecu) {
28
+ return String((ecu && (ecu._irFrom || ecu.sgbd)) || '').toLowerCase();
29
+ }
30
+
31
+ /** @type {Map<string, Promise<object|null>>} exec per SGBD, fetched once */
32
+ const _irExecCache = new Map();
33
+
34
+ /**
35
+ * {procs, byid} for an ECU, fetched once. null when the ECU ships no runnable
36
+ * twin (an orphan, or a pre-phase-1 archive).
37
+ * @param {string} sgbd - SGBD name
38
+ * @returns {Promise<object|null>}
39
+ */
40
+ function irLiveExec(sgbd) {
41
+ const key = String(sgbd).toLowerCase();
42
+ if (!_irExecCache.has(key)) {
43
+ _irExecCache.set(
44
+ key,
45
+ api(`/api/ecu/${key}/ipoexec`)
46
+ .then((x) => (x && x.procs ? x : null))
47
+ .catch(() => null)
48
+ );
49
+ }
50
+ return _irExecCache.get(key);
51
+ }
52
+
53
+ /**
54
+ * Put an exec into the cache under a name, so the runtime finds it there
55
+ * instead of fetching one.
56
+ *
57
+ * A script the user supplied is decoded in the browser and has no URL to be
58
+ * fetched from. Seeding it here is what lets it open through the ordinary
59
+ * module path -- same program driver, same confirmations, same write gates --
60
+ * rather than through a second runtime that would have to re-earn that trust.
61
+ * The entry lives only as long as the page.
62
+ *
63
+ * @param {string} sgbd - the name to file it under
64
+ * @param {object|null} exec - {procs, byid}
65
+ * @returns {void}
66
+ */
67
+ function irSeedExec(sgbd, exec) {
68
+ _irExecCache.set(String(sgbd).toLowerCase(), Promise.resolve(exec || null));
69
+ }
70
+
71
+ /**
72
+ * One INPA ask, in INPA's own words. The driven VM suspends at every input
73
+ * builtin; this renders the right dialog for its kind and returns what
74
+ * resume() stores: a number (inputint/inputnum), a hex STRING (inputhex),
75
+ * 1/0 from the two-choice box (inputdigital), or an ARRAY for the two-field
76
+ * forms (input2int's Kalenderwoche/Jahr) -- one element per out-ref.
77
+ * @param {IpoStep} step - the input suspension ({name, prompts, refs, lo, hi})
78
+ * @param {string} [fallbackTitle] - the title when the script gave no prompt
79
+ * @returns {Promise<number|string|Array<number|string>|null>} null =
80
+ * cancelled, and the keypress is abandoned
81
+ */
82
+ async function irAskInput(step, fallbackTitle) {
83
+ const refs = Math.max(1, Number(step.refs || 1));
84
+ const name = String(step.name || '');
85
+ const tr = (x) => esc(irLabel(x || '') || x || '');
86
+ const p0 = step.prompts[0] || fallbackTitle || '';
87
+ const p1 = step.prompts[1] || '';
88
+ if (name === 'inputdigital') {
89
+ // (out bool, title, text, FalseStr, TrueStr): the box offers the two
90
+ // words, nothing else -- INPA's yes/no. Which word PROCEEDS is the
91
+ // script's business (IHKA46 asks "Are you sure? yes/no" with yes = 0 and
92
+ // sends on 0), so neither button may double as "dismiss": Esc and the
93
+ // backdrop cancel the run instead of answering with one of them.
94
+ const f = step.prompts[step.prompts.length - 2] || 'OFF';
95
+ const t = step.prompts[step.prompts.length - 1] || 'ON';
96
+ const yes = await confirmDialog({
97
+ title: tr(p0),
98
+ body: tr(p1),
99
+ confirmLabel: tr(t),
100
+ cancelLabel: tr(f),
101
+ dismissValue: null,
102
+ });
103
+ if (yes == null) return null;
104
+ return yes ? 1 : 0;
105
+ }
106
+ // (out, title, text) with nothing else: INPA's plain OK/Cancel box -- the
107
+ // fault-memory clear asks "Do you really want to delete the error-memory?
108
+ // <OK>-Key or <Return>-Key clears!" and then tests getinputstate == 0.
109
+ // There is no value to type; asking for a number blocked the clear.
110
+ if (name === 'builtin_3f' && step.prompts.length <= 2 && refs === 1) {
111
+ const yes = await confirmDialog({
112
+ title: tr(p0),
113
+ body: tr(p1),
114
+ confirmLabel: 'OK',
115
+ cancelLabel: 'Cancel',
116
+ dismissValue: null,
117
+ });
118
+ return yes ? 0 : null;
119
+ }
120
+ const hex = /hex/i.test(name);
121
+ // input2text and its kin ask for words (a comment to save with the
122
+ // protocol), and an empty line is an answer there, not a cancel
123
+ const text = /text/i.test(name);
124
+ // inputnum asks for a real: decimals stay
125
+ const real = name === 'inputnum';
126
+ const vals = [];
127
+ for (let k = 0; k < refs; k++) {
128
+ // a two-field form captions each field after the title/help pair
129
+ const cap =
130
+ refs > 1 ? step.prompts[2 + k] || `${p0} (${k + 1}/${refs})` : p1;
131
+ const asked = await inputDialog({
132
+ title: tr(p0),
133
+ body:
134
+ tr(cap) +
135
+ (step.lo != null && step.hi != null && !hex
136
+ ? `<br><br>Accepted range <b>${step.lo}</b> to <b>${step.hi}</b>.`
137
+ : ''),
138
+ kind: hex || text ? 'text' : 'number',
139
+ example: hex || text ? '' : step.lo != null ? String(step.lo) : '',
140
+ confirmLabel: 'OK',
141
+ });
142
+ if (asked == null) return null;
143
+ if (text) {
144
+ vals.push(String(asked));
145
+ continue;
146
+ }
147
+ if (String(asked).trim() === '') return null;
148
+ if (hex) {
149
+ vals.push(String(asked).trim());
150
+ continue;
151
+ }
152
+ const n = real ? Number(asked) : Math.trunc(Number(asked));
153
+ if (!Number.isFinite(n)) return null;
154
+ if (
155
+ refs === 1 &&
156
+ step.lo != null &&
157
+ step.hi != null &&
158
+ (n < step.lo || n > step.hi)
159
+ )
160
+ return null;
161
+ vals.push(n);
162
+ }
163
+ return refs > 1 ? vals : vals[0];
164
+ }
165
+
166
+ /**
167
+ * Every job name an item's body (and its helpers, one level) can send: the
168
+ * job-name constant pushed before each INPAapiJob call.
169
+ * @param {object} exec - the decoded script ({procs, byid})
170
+ * @param {IpoToken[]} toks - the menu proc's tokens
171
+ * @param {number} i0 - first token index of the body
172
+ * @param {number} end - token index the body ends at (exclusive)
173
+ * @returns {string[]}
174
+ */
175
+ function irItemBodyJobs(exec, toks, i0, end) {
176
+ const out = [];
177
+ const scan = (tk, a, b, depth) => {
178
+ for (let i = a; i < Math.min(b, tk.length); i++) {
179
+ const t = tk[i];
180
+ if (t.op === 'call' && IR_JOB_CALL_RE.test(t.name || '')) {
181
+ for (let j = i - 1; j >= Math.max(0, i - IR_JOB_LOOKBACK); j--) {
182
+ const c = tk[j];
183
+ if (
184
+ c.op === 'const' &&
185
+ c.t === 's' &&
186
+ IR_JOB_NAME_RE.test(String(c.v))
187
+ ) {
188
+ if (!out.includes(c.v)) out.push(c.v);
189
+ break;
190
+ }
191
+ if (c.op === 'frame') break;
192
+ }
193
+ } else if (t.op === 'calluser' && depth < IR_JOB_SCAN_DEPTH) {
194
+ const nm = (exec.byid || {})[`func:${t.n}`];
195
+ const body = nm && exec.procs[nm];
196
+ if (Array.isArray(body)) scan(body, 0, body.length, depth + 1);
197
+ }
198
+ }
199
+ };
200
+ scan(toks, i0, end, 0);
201
+ return out;
202
+ }
203
+
204
+ // ---- captions: exact dictionaries only -----------------------------------
205
+ // Per-ECU map from data/inpa-i18n/<ECU>.json (shipped as ir.i18n), then the
206
+ // shared INPA chrome table (data/i18n-shared.js). No word rules: a caption
207
+ // with no entry shows as BMW wrote it.
208
+
209
+ /** @type {Record<string, string>|null} the current ECU's caption map */
210
+ let _irI18n = null;
211
+ /** @type {Map<string, string>|null} that map keyed by collapsed caption */
212
+ let _irI18nNorm = null;
213
+ /** @type {(Map<string, string> & {src?: object})|null} the shared table, collapsed */
214
+ let _irSharedNorm = null;
215
+
216
+ /**
217
+ * Take an ECU's caption dictionary as the current one.
218
+ * @param {{i18n?: Record<string, string>}|null} ir - the ECU's shipped IR
219
+ * @returns {void}
220
+ */
221
+ function irUseTranslations(ir) {
222
+ _irI18n = (ir && ir.i18n) || null;
223
+ _irI18nNorm = null;
224
+ }
225
+
226
+ /**
227
+ * The shared INPA chrome table, when data/i18n-shared.js is loaded.
228
+ * @returns {Record<string, string>|null}
229
+ */
230
+ function irI18nShared() {
231
+ return (typeof window !== 'undefined' && window.BMW_I18N_SHARED) || null;
232
+ }
233
+
234
+ /**
235
+ * A caption's lookup form. The .IPO prints a caption padded to its column
236
+ * ("Drehzahl :") and the same words appear elsewhere trimmed; both mean
237
+ * one thing, so whitespace collapses and a trailing ':' or '=' is dropped.
238
+ * @param {string} s - the caption
239
+ * @returns {string}
240
+ */
241
+ function irI18nKey(s) {
242
+ return String(s)
243
+ .replace(/\s+/g, ' ')
244
+ .trim()
245
+ .replace(/\s*[:=]\s*$/, '');
246
+ }
247
+
248
+ /**
249
+ * A dictionary keyed by collapsed caption (first entry wins).
250
+ * @param {Record<string, string>} map - the dictionary
251
+ * @returns {Map<string, string>}
252
+ */
253
+ function irNormMap(map) {
254
+ const out = new Map();
255
+ for (const k of Object.keys(map)) {
256
+ const nk = irI18nKey(k);
257
+ if (nk && !out.has(nk)) out.set(nk, map[k]);
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * INPA's key-legend notation: a menu screen prints "< F4 > Fehlerspeicher
264
+ * lesen" or "< Shift > + < F6 > EWS" as ONE string. The caption after the
265
+ * key is what the dictionaries carry, so it is looked up on its own and the
266
+ * key prefix is kept as printed. Syntax of the notation only, no word rules.
267
+ * Group 1 = the key prefix as printed, 2 = the caption.
268
+ * @type {RegExp}
269
+ */
270
+ const IR_KEY_LEGEND =
271
+ /^(\s*(?:<\s*Shift\s*>\s*\+\s*)?<\s*F\s*\d+\s*>\s*)(\S.*)$/i;
272
+
273
+ /**
274
+ * Translate a caption through the exact dictionaries: the ECU's own map,
275
+ * then the shared table, first as written, then by its collapsed form with
276
+ * the original's leading indentation and trailing ':' put back so a
277
+ * translated cell keeps its place on the grid. "Function labels: Original
278
+ * (EDIABAS)" (lang() === 'orig') shows BMW's own strings verbatim.
279
+ * @param {string} s - the caption as the script printed it
280
+ * @returns {string} the translation, or `s` when no dictionary has it
281
+ */
282
+ function irLabel(s) {
283
+ if (!s) return s;
284
+ if (typeof lang === 'function' && lang() === 'orig') return s;
285
+ const shared = irI18nShared();
286
+ if (!_irI18n && !shared) return s;
287
+ const has = (m) => m && Object.prototype.hasOwnProperty.call(m, s);
288
+ const legend = typeof s === 'string' ? s.match(IR_KEY_LEGEND) : null;
289
+ if (legend && !has(_irI18n) && !has(shared)) {
290
+ return legend[1] + irLabel(legend[2]);
291
+ }
292
+ if (has(_irI18n)) return _irI18n[s];
293
+ if (has(shared)) return shared[s];
294
+ const str = String(s);
295
+ const nk = irI18nKey(str);
296
+ let hit = null;
297
+ if (_irI18n) {
298
+ if (!_irI18nNorm) _irI18nNorm = irNormMap(_irI18n);
299
+ hit = _irI18nNorm.get(nk);
300
+ }
301
+ if (hit == null && shared) {
302
+ if (!_irSharedNorm || _irSharedNorm.src !== shared) {
303
+ _irSharedNorm = irNormMap(shared);
304
+ _irSharedNorm.src = shared;
305
+ }
306
+ hit = _irSharedNorm.get(nk);
307
+ }
308
+ if (hit == null) return s;
309
+ const lead = (str.match(/^\s*/) || [''])[0];
310
+ const tail = (str.match(/\s*[:=]?\s*$/) || [''])[0];
311
+ return lead + hit + tail;
312
+ }
313
+
314
+ if (typeof module !== 'undefined' && module.exports) {
315
+ module.exports = {
316
+ irExecSgbd,
317
+ irLiveExec,
318
+ irSeedExec,
319
+ irAskInput,
320
+ irItemBodyJobs,
321
+ irUseTranslations,
322
+ irLabel,
323
+ };
324
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file Loading the corpus-wide job search index, once.
3
+ *
4
+ * The index is built at export time (tools/export/search_index.py) and ships
5
+ * as one gzipped file: every INPA key and screen in every script the build
6
+ * carries, with the jobs each sends. Scanning the corpus in the browser would
7
+ * mean downloading every .chassis archive, so this fetches the finished
8
+ * answer -- ~2 MB, cached by the service worker like the rest of api/.
9
+ *
10
+ * The fetch goes through webRealFetch (the FILE, not the shim's /api/ router)
11
+ * because the index is a static artifact, not a route the shim computes.
12
+ */
13
+
14
+ /* exported SEARCH_INDEX_VERSION, searchIndexLoad, searchIndexPeek, searchIndexPresent */
15
+
16
+ /** The index format this code understands; the builder stamps it as `v`. */
17
+ const SEARCH_INDEX_VERSION = 1;
18
+
19
+ /**
20
+ * The whole index as the builder writes it.
21
+ * @typedef {object} SearchIndex
22
+ * @property {number} v - format version
23
+ * @property {SearchModule[]} modules - one per SGBD, entries point into this
24
+ * @property {SearchEntry[]} entries - the keys and screens
25
+ */
26
+
27
+ /**
28
+ * One module: a script, and the cars that carry it.
29
+ * @typedef {object} SearchModule
30
+ * @property {string} sgbd - the .prg name, lower-case; the deep link's module part
31
+ * @property {string} label - the module's human name ("MS45.1 for M54")
32
+ * @property {string} code - INPA's own designation for it (MS450), may be ''
33
+ * @property {string[]} chassis - chassis ids that carry it, upper-case
34
+ * @property {number} [vehicle] - 1 when this is a whole-vehicle script
35
+ */
36
+
37
+ /**
38
+ * One searchable thing: a key a user can press, or a screen the script shows.
39
+ * Field names are one or two letters because the file holds ~90k of these.
40
+ * @typedef {object} SearchEntry
41
+ * @property {'k'|'s'} t - key or screen
42
+ * @property {number} i - index into `modules`
43
+ * @property {string} [m] - the menu proc the key sits on (keys only)
44
+ * @property {number} [n] - the F-key number, 11..20 = shifted (keys only)
45
+ * @property {string} [l] - the key's label as the script prints it
46
+ * @property {string} [e] - that label in English, when a dictionary has it
47
+ * @property {string} [s] - the screen proc this opens (keys) or is (screens)
48
+ * @property {string} [ti] - the screen's title
49
+ * @property {string} [tie] - that title in English
50
+ * @property {string[]} [j] - job names it sends
51
+ * @property {string[]} [k] - result keys the screen paints (screens only)
52
+ * @property {string} [c] - the screen's static captions, joined (screens only)
53
+ * @property {string} [ce] - those captions in English
54
+ * @property {number} [w] - 1 when the key writes to the module
55
+ * @property {string} [a] - the key's non-job action, when it has one
56
+ */
57
+
58
+ /** @type {SearchIndex|null} the loaded index */
59
+ let searchIndex = null;
60
+
61
+ /** @type {Promise<SearchIndex|null>|null} the in-flight load, shared */
62
+ let searchIndexPromise = null;
63
+
64
+ /**
65
+ * Load the index, once. Concurrent callers share one fetch, and a failure is
66
+ * remembered as "no index" rather than retried on every keystroke.
67
+ * @returns {Promise<SearchIndex|null>} the index, or null when it cannot load
68
+ */
69
+ function searchIndexLoad() {
70
+ if (searchIndex) return Promise.resolve(searchIndex);
71
+ if (searchIndexPromise) return searchIndexPromise;
72
+ const base = typeof WEB_BASE === 'string' ? WEB_BASE : '';
73
+ const real =
74
+ typeof webRealFetch === 'function'
75
+ ? webRealFetch
76
+ : window.fetch.bind(window);
77
+ searchIndexPromise = (async () => {
78
+ try {
79
+ const r = await real(`${base}/api/search-index.json.gz`);
80
+ if (!r || !r.ok) return null;
81
+ const buf = new Uint8Array(await r.arrayBuffer());
82
+ // a host that content-decodes hands back plain JSON; both are taken
83
+ const gz = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b;
84
+ if (gz && typeof fflate === 'undefined') return null;
85
+ const text = new TextDecoder('utf-8').decode(
86
+ gz ? fflate.gunzipSync(buf) : buf
87
+ );
88
+ const doc = JSON.parse(text);
89
+ // a format the app does not understand would be read field-by-field
90
+ // into nonsense results; refusing it shows the empty state instead
91
+ if (!doc || doc.v !== SEARCH_INDEX_VERSION || !Array.isArray(doc.entries))
92
+ return null;
93
+ searchIndex = doc;
94
+ return doc;
95
+ } catch {
96
+ return null;
97
+ }
98
+ })();
99
+ return searchIndexPromise;
100
+ }
101
+
102
+ /**
103
+ * The index if it is already loaded, without starting a load.
104
+ * @returns {SearchIndex|null}
105
+ */
106
+ function searchIndexPeek() {
107
+ return searchIndex;
108
+ }
109
+
110
+ /** @type {Promise<boolean>|null} the in-flight presence probe, shared */
111
+ let searchIndexPresentPromise = null;
112
+
113
+ /**
114
+ * Whether this build ships an index at all, WITHOUT downloading it.
115
+ *
116
+ * The Apps hub greys a card whose data did not ship, and asks every app that
117
+ * question when it draws. Answering it with a full load would make opening
118
+ * the hub cost the 2 MB the app itself costs, so this asks for the headers
119
+ * only. A HEAD a static host does not answer falls back to the loaded index
120
+ * when one is already in hand, and otherwise reports present -- an app card
121
+ * wrongly shown opens a screen that explains itself, while one wrongly hidden
122
+ * cannot be found at all.
123
+ * @returns {Promise<boolean>}
124
+ */
125
+ function searchIndexPresent() {
126
+ if (searchIndex) return Promise.resolve(true);
127
+ if (searchIndexPresentPromise) return searchIndexPresentPromise;
128
+ const base = typeof WEB_BASE === 'string' ? WEB_BASE : '';
129
+ const real =
130
+ typeof webRealFetch === 'function'
131
+ ? webRealFetch
132
+ : window.fetch.bind(window);
133
+ searchIndexPresentPromise = (async () => {
134
+ try {
135
+ const r = await real(`${base}/api/search-index.json.gz`, {
136
+ method: 'HEAD',
137
+ });
138
+ return !!(r && r.ok);
139
+ } catch {
140
+ return true;
141
+ }
142
+ })();
143
+ return searchIndexPresentPromise;
144
+ }
145
+
146
+ if (typeof module !== 'undefined' && module.exports) {
147
+ module.exports = {
148
+ SEARCH_INDEX_VERSION,
149
+ searchIndexLoad,
150
+ searchIndexPeek,
151
+ searchIndexPresent,
152
+ };
153
+ }