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
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:
+199
-340
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user