feat(ui): unsupported-engine boot screen; drop the temporary diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s

Replace the temporary on-device boot diagnostic (the always-on overlay, the
console.error mirror and the handleError console hook) with a permanent boot-capability
guard in index.html. Before the deferred module, in plain ES5, it:
  - hard-gates on the unpolyfillable essentials the app cannot run without — BigInt (the
    64-bit FlatBuffers wire decode) and Proxy (Svelte 5 runes) — and, when one is
    missing, shows a friendly full-screen "this device's OS/browser can't run the app"
    screen instead of a white screen;
  - soft-gates the polyfillable es2020+ globals, pulling core-js (polyfills.js) only
    when needed (the previous behaviour, folded in);
  - keeps a reactive net: an uncaught error during boot with no window.__booted signal
    within 8s raises the same screen with the captured cause.
Inside a Telegram/VK Mini App the screen also points to the web version
(https://<host>/app/). A "Diagnostic information" button reveals the engine, the
feature table, the reason and the app version, with a Copy button.

App.svelte sets window.__booted once bootstrap resolves. vite.config drops the
now-unneeded strip-boot-diag plugin and adds inject-boot-version (stamps the guard's
diagnostic with VITE_APP_VERSION). The es2019 target, the core-js loader and the board
cqw->vmin glyph fallback stay. The speculative Chrome-74 pan change is reverted — it did
not fix the judder and added nothing on modern engines.

Verified: svelte-check clean, unit + mock e2e green (the guard is inert on a capable
engine, so the size budget and the smoke are untouched), and the screen + diagnostic +
stamped version render on a forced hard-gate (BigInt removed via addInitScript).

The unsupported-engine telemetry beacon (dedup + edge rate-limit + a Prometheus counter)
is a separate follow-up PR, per the agreed split.
This commit is contained in:
Ilia Denisov
2026-07-04 20:23:19 +02:00
parent 8499f388af
commit 2729fe9467
5 changed files with 248 additions and 400 deletions
+199 -340
View File
@@ -2,30 +2,207 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<!-- Old-engine polyfills. An old Android System WebView (seen: Chromium 66 in the Telegram/VK
in-app browser) supports ES modules but lacks the es2020+ runtime globals the bundle and its
deps call (globalThis, structuredClone, Array.at, …); they throw a ReferenceError, the module
never mounts and the SPA is a white screen. Feature-detect that and synchronously pull core-js
(emitted as polyfills.js) BEFORE the deferred module runs. A modern engine matches none of
these checks and loads nothing — zero extra bytes, and polyfills.js stays out of the module
graph the bundle-size budget measures. document.write is deliberate here: it is the only way
to inject a parser-blocking <script> guaranteed to execute before the deferred module (a
dynamically-appended script is async and could run after it). The written string is a static
literal — no interpolation, so no injection surface. -->
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
on an engine too old to parse the bundle. Three jobs, as early as possible:
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
this guards) instead of a white screen.
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
injection surface.
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
signalled window.__booted within a grace period AND an error fired, show the same
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
incompatibility). __booted is set in App.svelte once bootstrap resolves.
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
budget nor the mock e2e is affected. -->
<script>
(function () {
var need =
typeof globalThis === 'undefined' ||
typeof structuredClone === 'undefined' ||
typeof WeakRef === 'undefined' ||
typeof queueMicrotask === 'undefined' ||
!Array.prototype.at ||
!Array.prototype.findLast ||
!Object.hasOwn ||
!Object.fromEntries ||
!Promise.allSettled ||
!Promise.any;
if (need) document.write('<script src="polyfills.js"><\/script>');
'use strict';
var nav = navigator;
var ua = nav.userAgent || '';
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
var WEBURL = location.protocol + '//' + location.host + '/app/';
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var probes = [
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
['Promise.any', function () { return !!Promise.any; }, 85, false],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
];
var results = [];
var hardMissing = [];
var softMissing = false;
for (var i = 0; i < probes.length; i++) {
var ok = has(probes[i][1]);
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
if (!ok) {
if (probes[i][3]) hardMissing.push(probes[i][0]);
else softMissing = true;
}
}
// The diagnostic report (built on demand, behind the button).
function diag(reason) {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var out = [];
out.push('reason : ' + reason);
out.push('app : ' + VERSION);
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
out.push('userAgent : ' + ua);
out.push('url : ' + location.href);
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
out.push('');
out.push('features:');
for (var k = 0; k < results.length; k++) {
var r = results[k];
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
}
return out.join('\n');
}
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
function btn(bg, fg) {
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
}
var shown = false;
function show(reason) {
if (shown) return;
shown = true;
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
var wrap = el('div', 'max-width:520px;margin:0 auto;');
root.appendChild(wrap);
var msg = el('div', null);
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
msg.appendChild(el('p', 'margin:0 0 14px;', RU
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
: "This app can't run on your device's operating system or browser version."));
if (MINIAPP) {
msg.appendChild(el('p', 'margin:0 0 6px;', RU
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
: 'Open the web version in a regular browser (Chrome, Firefox):'));
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
a.setAttribute('href', WEBURL);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
var lp = el('p', 'margin:0 0 18px;');
lp.appendChild(a);
msg.appendChild(lp);
}
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
msg.appendChild(infoBtn);
wrap.appendChild(msg);
var dv = el('div', 'display:none;');
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
dv.appendChild(pre);
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
var row = el('div', 'margin-top:12px;');
row.appendChild(copyBtn);
row.appendChild(backBtn);
dv.appendChild(row);
wrap.appendChild(dv);
infoBtn.onclick = function () {
msg.style.display = 'none';
dv.style.display = 'block';
};
backBtn.onclick = function () {
dv.style.display = 'none';
msg.style.display = 'block';
};
copyBtn.onclick = function () {
var txt = diag(reason);
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
}
};
function mount() {
document.body.insertBefore(root, document.body.firstChild);
}
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
// 1 + 2: the gate.
if (hardMissing.length) {
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '));
} else if (softMissing) {
document.write('<script src="polyfills.js"><\/script>');
}
// 3: the reactive net for an unforeseen boot failure.
var bootErr = null;
function note(m) {
if (!bootErr) bootErr = m;
}
window.onerror = function (m, s, l, c, e) {
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
return false;
};
window.addEventListener('error', function (e) {
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}, true);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
setTimeout(function () {
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr);
}, 8000);
})();
</script>
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
@@ -50,324 +227,6 @@
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
</head>
<body>
<!-- BOOT-DIAG:START — TEMPORARY on-device boot diagnostic (test contour only).
A classic (non-module) ES5 <script> placed BEFORE the ES2022 module bundle. It runs and
paints even when the bundle fails to parse on an old Android System WebView (the case we
are chasing: Firefox/Gecko renders the SPA on the same device, the in-app WebView shows a
white screen). It installs error capture first, so a module SyntaxError / load failure is
printed here, then reports the engine (UA + Chromium version), the JS/Web feature support
the bundle needs, and whether the module ran / Svelte mounted.
vite.config.ts (strip-boot-diag) removes this whole block from every non-production build,
so the mock e2e overlay stays clear. Delete this block AND that plugin after the diagnosis
— it must never reach master / production. -->
<script>
(function () {
'use strict';
var t0 = Date.now();
var nav = navigator;
// ---- overlay shell ---------------------------------------------------------------------
var root = document.createElement('div');
root.id = 'bootdiag';
root.setAttribute(
'style',
'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0b0b0e;color:#e6e6e6;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;box-sizing:border-box;'
);
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
root.appendChild(el('div', 'font-size:15px;font-weight:700;margin-bottom:2px;', 'BOOT DIAGNOSTIC'));
root.appendChild(
el('div', 'color:#7ee081;margin-bottom:10px;', 'HTML parsed and classic JS is running (you can read this).')
);
var btnStyle =
'display:inline-block;margin:0 8px 12px 0;padding:10px 16px;border:0;border-radius:6px;' +
'font:600 13px/1 inherit;color:#0b0b0e;background:#7ee081;';
var hideBtn = el('button', btnStyle, 'Hide / continue to app');
var copyBtn = el('button', btnStyle.replace('#7ee081', '#8ab4ff'), 'Copy report');
var bar = el('div', null);
bar.appendChild(hideBtn);
bar.appendChild(copyBtn);
root.appendChild(bar);
function section(title) {
root.appendChild(el('div', 'margin:12px 0 4px;font-weight:700;color:#9aa0aa;', title));
var pre = el('pre', 'margin:0;white-space:pre-wrap;word-break:break-word;');
root.appendChild(pre);
return pre;
}
// ---- error capture (installed before the module runs) ----------------------------------
var errBox = el('div', 'margin:12px 0 4px;font-weight:700;color:#ff8080;', 'ERRORS (from the module bundle)');
errBox.style.display = 'none';
var errPre = el('pre', 'margin:0;white-space:pre-wrap;word-break:break-word;color:#ffb3b3;');
errPre.style.display = 'none';
function stamp() {
return '[t+' + ((Date.now() - t0) / 1000).toFixed(2) + 's] ';
}
function logErr(prefix, msg) {
errBox.style.display = 'block';
errPre.style.display = 'block';
errPre.appendChild(document.createTextNode((errPre.firstChild ? '\n' : '') + stamp() + prefix + ': ' + msg));
}
window.onerror = function (message, source, lineno, colno, error) {
logErr(
'window.onerror',
String(message) +
(source ? ' @ ' + source + ':' + lineno + ':' + colno : '') +
(error && error.stack ? '\n' + error.stack : '')
);
return false;
};
window.addEventListener(
'error',
function (e) {
var tgt = e && e.target;
if (tgt && tgt !== window && (tgt.src || tgt.href)) {
logErr('resource load error', (tgt.tagName || '?') + ' ' + (tgt.src || tgt.href));
} else if (e && e.message) {
logErr('error event', e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}
},
true
);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
logErr('unhandledrejection', r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
// Mirror console.error / console.warn into the panel. The app catches its boot failures and
// surfaces them only as a toast (they never reach window.onerror); a temporary console.error
// at the app's handleError routes the real cause here, so the panel shows what actually threw.
function fmt(a) {
if (a && (a.stack || a.message)) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch (e) {
return String(a);
}
}
return String(a);
}
function wrapConsole(orig, label) {
return function () {
try {
var parts = [];
for (var i = 0; i < arguments.length; i++) parts.push(fmt(arguments[i]));
logErr('console.' + label, parts.join(' '));
} catch (e) {}
if (orig) return orig.apply(console, arguments);
};
}
if (window.console) {
console.error = wrapConsole(console.error, 'error');
console.warn = wrapConsole(console.warn, 'warn');
}
// ---- environment -----------------------------------------------------------------------
var ua = nav.userAgent || '(no userAgent)';
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var chromeMajor = cm ? parseInt(cm[1], 10) : null;
var isWV = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var envPre = section('ENGINE');
var envLines = [
'userAgent : ' + ua,
'Chromium : ' + (chromeMajor != null ? chromeMajor : 'not a Chromium UA') + (isWV ? ' (Android WebView "wv")' : ''),
'vendor : ' + (nav.vendor || '(none)'),
'url : ' + location.href,
'viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @dpr ' + (window.devicePixelRatio || 1),
'cookies : ' + nav.cookieEnabled + ' online: ' + nav.onLine + ' lang: ' + (nav.language || '?'),
'readyState: ' + document.readyState,
];
envPre.appendChild(document.createTextNode(envLines.join('\n')));
// ---- JS syntax probes (parsed via new Function so a probe never crashes the engine) -----
var evalWorks = true;
try {
new Function('return 1');
} catch (e) {
evalWorks = false;
}
function synOk(body) {
try {
new Function(body);
return true;
} catch (e) {
return false;
}
}
// [label, function-body-to-parse, Chrome version it landed in]
var syn = [
['optional chaining a?.b', 'var o={};return o?.b', 80],
['nullish coalescing a ?? b', 'return (null ?? 1)', 80],
['nullish assignment a ??= b', 'var a=null;a??=2;return a', 85],
['logical-or assignment a ||= b', 'var a=0;a||=2;return a', 85],
['private class field #x', 'class C{#v=1;r(){return this.#v}}return new C().r()', 74],
['static class block', 'class C{static v;static{C.v=1}}return C.v', 94],
['object spread {...o}', 'var o={a:1};return {b:1,...o}', 60],
['numeric separator 1_000', 'return 1_000', 75],
['dynamic import()', 'return import("")', 63],
];
var synPre = section('JS SYNTAX (engine feature ceiling — first NO dates the engine)');
function mark(ok) {
return ok ? 'OK ' : 'NO ';
}
var synFail = 0;
var baselineBroken = false;
if (!evalWorks) {
synPre.appendChild(document.createTextNode('eval / new Function is blocked (CSP?) — syntax probes skipped; rely on ENGINE + APIS.'));
} else {
var synText = [];
for (var i = 0; i < syn.length; i++) {
var ok = synOk(syn[i][1]);
if (!ok) {
synFail++;
if (syn[i][2] <= 80) baselineBroken = true; // ?. / ?? missing ⇒ the bundle cannot parse
}
synText.push(mark(ok) + syn[i][0] + ' (Chrome ' + syn[i][2] + ')');
}
synPre.appendChild(document.createTextNode(synText.join('\n')));
}
// ---- JS / Web API probes ---------------------------------------------------------------
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var api = [
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76],
['Promise.any', function () { return !!Promise.any; }, 85],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71],
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67],
['Proxy (Svelte 5 runes need it)', function () { return typeof Proxy !== 'undefined'; }, 49],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71],
['crypto.subtle', function () { return !!(window.crypto && window.crypto.subtle); }, 37],
['CSS.supports', function () { return !!(window.CSS && window.CSS.supports); }, 28],
['ResizeObserver', function () { return typeof ResizeObserver !== 'undefined'; }, 64],
['IntersectionObserver', function () { return typeof IntersectionObserver !== 'undefined'; }, 51],
['fetch', function () { return typeof fetch === 'function'; }, 42],
['AbortController', function () { return typeof AbortController !== 'undefined'; }, 66],
['Intl.Segmenter', function () { return !!(window.Intl && Intl.Segmenter); }, 87],
['localStorage (may be blocked in WebView)', function () {
var k = '__bd';
localStorage.setItem(k, '1');
localStorage.removeItem(k);
return true;
}, 4],
];
var apiPre = section('JS / WEB APIS');
var apiFail = 0;
var apiText = [];
for (var j = 0; j < api.length; j++) {
var okj = has(api[j][1]);
if (!okj) apiFail++;
apiText.push(mark(okj) + api[j][0] + ' (Chrome ' + api[j][2] + ')');
}
apiPre.appendChild(document.createTextNode(apiText.join('\n')));
// ---- verdict ---------------------------------------------------------------------------
var verdict = section('VERDICT');
var vLines = [];
if (baselineBroken) {
vLines.push('LIKELY CAUSE: engine lacks ES2020 optional-chaining / nullish coalescing —');
vLines.push('the es2022 module bundle cannot be parsed, so the SPA never mounts (white screen).');
} else if (synFail > 0 || apiFail > 0) {
vLines.push(synFail + ' syntax + ' + apiFail + ' API features missing. If the module error below');
vLines.push('names one of them, that is the blocker; otherwise the engine is likely new enough.');
} else {
vLines.push('All probed features supported — engine is not the blocker.');
vLines.push('If the SPA still fails, read the module ERRORS below and the MOUNT check.');
}
verdict.appendChild(document.createTextNode(vLines.join('\n')));
// ---- module / mount check (no coupling: reads the DOM the bundle would touch) -----------
var mountPre = section('SPA MOUNT CHECK (does the ES2022 module actually run + mount?)');
function mountLine() {
var shell = document.documentElement.className.indexOf('app-shell') >= 0; // main.ts line 8
var appEl = document.getElementById('app');
var n = appEl ? appEl.childElementCount : -1;
var verdictTxt = n > 0 ? 'SPA mounted' : shell ? 'module RAN but Svelte did NOT mount' : 'module did NOT run (parse/load failure)';
return stamp() + '<html.app-shell>=' + (shell ? 'YES' : 'no') + ' #app children=' + n + ' → ' + verdictTxt;
}
function addMount() {
mountPre.appendChild(document.createTextNode((mountPre.firstChild ? '\n' : '') + mountLine()));
}
// First reading at DOMContentLoaded: deferred module scripts (the ES2022 bundle) have
// executed by then, so #app is either mounted or provably not — a reading taken any earlier
// races the parser (the #app div is emitted after this script) and always reads "not run".
// The later readings catch a slow / async mount and show whether it held.
function scheduleMounts() {
addMount();
setTimeout(addMount, 3000);
setTimeout(addMount, 8000);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scheduleMounts);
else scheduleMounts();
// ---- errors area goes last -------------------------------------------------------------
root.appendChild(errBox);
root.appendChild(errPre);
// ---- buttons ---------------------------------------------------------------------------
hideBtn.onclick = function () {
root.style.display = 'none';
};
function fullReport() {
return (
envLines.join('\n') +
'\n\n=== VERDICT ===\n' + vLines.join('\n') +
'\n\n=== JS SYNTAX ===\n' + (evalWorks ? synPre.textContent : '(eval blocked)') +
'\n\n=== JS/WEB APIS ===\n' + apiText.join('\n') +
'\n\n=== SPA MOUNT ===\n' + mountPre.textContent +
'\n\n=== ERRORS ===\n' + (errPre.textContent || '(none)')
);
}
copyBtn.onclick = function () {
var txt = fullReport();
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = 'Copy failed — long-press the text';
}
};
// Attach as the first body child, above #app, so it overlays the (possibly blank) SPA.
if (document.body) document.body.insertBefore(root, document.body.firstChild);
else document.addEventListener('DOMContentLoaded', function () { document.body.insertBefore(root, document.body.firstChild); });
})();
</script>
<!-- BOOT-DIAG:END -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+6 -1
View File
@@ -26,7 +26,12 @@
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => {
void bootstrap();
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
void bootstrap().then(() => {
(window as unknown as { __booted?: boolean }).__booted = true;
});
});
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
+32 -38
View File
@@ -228,38 +228,6 @@
let sy = 0;
let sl = 0;
let st = 0;
// Track the drag on window rather than vp.setPointerCapture. Capturing the pointer on the very
// element being scrolled makes an older Chromium — the Chrome 74 Android 10 System WebView —
// drop or displace the capture as scrollLeft/Top are written mid-drag, so the board judders and
// will not pan. Window listeners give the same "keep receiving moves past the element edge"
// without capturing the scroll container; modern engines behave identically.
const onMove = (e: PointerEvent) => {
if (!armed) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!panning) {
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
panning = true;
}
vp.scrollLeft = sl - dx;
vp.scrollTop = st - dy;
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
armed = false;
if (!panning) return;
panning = false;
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
// cell); self-remove on that click, and clean up on the next tick if none fired.
const swallow = (ev: Event) => {
ev.stopPropagation();
vp.removeEventListener('click', swallow, true);
};
vp.addEventListener('click', swallow, true);
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
};
const onDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
@@ -269,16 +237,42 @@
sy = e.clientY;
sl = vp.scrollLeft;
st = vp.scrollTop;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
};
const onMove = (e: PointerEvent) => {
if (!armed) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!panning) {
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
panning = true;
vp.setPointerCapture(e.pointerId);
}
vp.scrollLeft = sl - dx;
vp.scrollTop = st - dy;
};
const onUp = (e: PointerEvent) => {
armed = false;
if (!panning) return;
panning = false;
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
// cell); self-remove on that click, and clean up on the next tick if none fired.
const swallow = (ev: Event) => {
ev.stopPropagation();
vp.removeEventListener('click', swallow, true);
};
vp.addEventListener('click', swallow, true);
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
};
vp.addEventListener('pointerdown', onDown);
vp.addEventListener('pointermove', onMove);
vp.addEventListener('pointerup', onUp);
vp.addEventListener('pointercancel', onUp);
return () => {
vp.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
vp.removeEventListener('pointermove', onMove);
vp.removeEventListener('pointerup', onUp);
vp.removeEventListener('pointercancel', onUp);
};
});
-6
View File
@@ -283,12 +283,6 @@ export function markChatRead(gameId: string): void {
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
* header indicator (and auto-retried), never a red toast. */
export function handleError(err: unknown): void {
// TEMP (boot diagnostic): the toast is generic, so route the real caught error to the on-device
// BOOT-DIAG panel via console (it mirrors console.error). Mode-gated to keep it out of the mock
// e2e. Revert together with the index.html BOOT-DIAG block.
if (import.meta.env.MODE !== 'mock') {
console.error('[diag] handleError:', err instanceof Error ? err.stack || err.message : err);
}
const code = err instanceof GatewayError ? err.code : '';
if (code === 'session_invalid' || code === 'unauthenticated') {
void logout();
+11 -15
View File
@@ -4,19 +4,17 @@ import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* stripBootDiag removes the temporary on-device boot-diagnostic block from index.html — the span
* between the `<!-- BOOT-DIAG:START -->` and `<!-- BOOT-DIAG:END -->` markers. That block is a
* full-screen ES5 overlay meant only for the production build shipped to the test contour, so it
* is dropped from every non-production build: the `mock` e2e (whose first taps the overlay would
* intercept) and the dev server. It is wired only when mode !== 'production'. Remove this plugin
* together with the index.html block once the Android WebView diagnosis is finished — it must
* never reach master / production.
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
*/
function stripBootDiag(): Plugin {
function injectBootVersion(): Plugin {
const version = process.env.VITE_APP_VERSION || 'dev';
return {
name: 'strip-boot-diag',
name: 'inject-boot-version',
transformIndexHtml(html) {
return html.replace(/\s*<!-- BOOT-DIAG:START[\s\S]*?BOOT-DIAG:END -->/g, '');
return html.replace(/__BOOT_VERSION__/g, version);
},
};
}
@@ -56,11 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
},
// emitPolyfills always ships dist/polyfills.js (loaded only by old engines; see its docstring
// and the index.html gate). The boot-diagnostic overlay ships only in the production build (the
// test contour) — it is stripped from the dev server and the mock e2e build, where a full-screen
// overlay would intercept Playwright's taps. See stripBootDiag / the index.html BOOT-DIAG block.
plugins: [svelte(), emitPolyfills(), ...(mode !== 'production' ? [stripBootDiag()] : [])],
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
server: {
port: 5173,
proxy: