51147a1429
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Make the web SPA an installable PWA and surface it to users: - manifest.webmanifest + an install-only service worker + 192/512/maskable icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type registered in the gateway (the distroless image has no /etc/mime.types). - Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa): one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari, hidden elsewhere / once installed / inside a Mini App. Shown under the logged-out login card and at the bottom of Settings. - The landing gains a third entry linking /app/ (the brand tile), with a caption under all three (Telegram / VK / Веб-версия). The service worker is navigation-only (network-first, cached-shell fallback); hashed assets and the Connect stream are untouched. It exists to satisfy Chromium's installability requirement and is the single growth point for a future opt-in offline mode. Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru), gateway README.
277 lines
14 KiB
HTML
277 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<!-- 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 () {
|
|
'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;
|
|
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
|
|
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
|
|
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
|
|
// fetch fallback; both are best-effort and never block or throw.
|
|
function beacon(code) {
|
|
try {
|
|
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
|
|
var chromium = cm ? cm[1] : '';
|
|
var sig = VERSION + '|' + code + '|' + chromium;
|
|
try {
|
|
if (localStorage.getItem('scrabble_unsupp') === sig) return;
|
|
} catch (e) {}
|
|
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
|
|
var sent = false;
|
|
try {
|
|
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
|
|
} catch (e) {}
|
|
if (!sent && typeof fetch === 'function') {
|
|
try {
|
|
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
|
|
sent = true;
|
|
} catch (e) {}
|
|
}
|
|
if (sent) {
|
|
try {
|
|
localStorage.setItem('scrabble_unsupp', sig);
|
|
} catch (e) {}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
function show(reason, code) {
|
|
if (shown) return;
|
|
shown = true;
|
|
beacon(code);
|
|
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(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
|
|
} 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, 'boot_error');
|
|
}, 8000);
|
|
})();
|
|
</script>
|
|
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
|
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
|
reach this tag). -->
|
|
<meta name="robots" content="noindex" />
|
|
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
|
|
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
|
|
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
|
|
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
|
|
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
|
|
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
|
|
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
|
|
<meta
|
|
name="viewport"
|
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
|
/>
|
|
<title>Эрудит (Скрэббл)</title>
|
|
<!-- Relative icon hrefs: the gateway serves the same build under /app/, /telegram/ and /vk/. -->
|
|
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
|
<!-- PWA installability (web /app/ only): the manifest + a service worker (registered from
|
|
lib/pwa, web-only) let the app be added to the home screen / desktop. The relative href
|
|
resolves per serving path; these tags are inert inside the Telegram/VK Mini App webviews,
|
|
which manage their own chrome. theme-color mirrors the light/dark --bg tokens (app.css). -->
|
|
<link rel="manifest" href="manifest.webmanifest" />
|
|
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
|
|
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
<meta name="apple-mobile-web-app-title" content="Эрудит" />
|
|
<meta name="application-name" content="Эрудит" />
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script type="module" src="/src/main.ts"></script>
|
|
</body>
|
|
</html>
|