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.
89 lines
3.5 KiB
TypeScript
89 lines
3.5 KiB
TypeScript
// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed
|
|
// state, and registers the install-only service worker. The pure platform branching lives in
|
|
// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts).
|
|
|
|
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
|
|
import { insideTelegram } from './telegram';
|
|
import { insideVK } from './vk';
|
|
|
|
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
|
|
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
|
|
* pwa.ts, so that module stays free of app imports for the node unit tests. */
|
|
function inMiniApp(): boolean {
|
|
return insideTelegram() || insideVK();
|
|
}
|
|
|
|
// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types.
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
|
prompt(): Promise<void>;
|
|
}
|
|
declare global {
|
|
interface WindowEventMap {
|
|
beforeinstallprompt: BeforeInstallPromptEvent;
|
|
appinstalled: Event;
|
|
}
|
|
}
|
|
|
|
// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription).
|
|
const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({
|
|
deferred: null,
|
|
installed: isStandalone(),
|
|
});
|
|
|
|
/**
|
|
* installMode is the reactive install-CTA mode for the current platform and any captured prompt.
|
|
* Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives
|
|
* (beforeinstallprompt fires shortly after load) or the app is installed.
|
|
*/
|
|
export function installMode(): InstallMode {
|
|
return resolveInstallMode({
|
|
deferredAvailable: install.deferred !== null,
|
|
standalone: install.installed,
|
|
iosSafari: isIosSafari(),
|
|
inMiniApp: inMiniApp(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* initInstallListeners wires the Chromium install prompt and the installed event. Harmless on
|
|
* any platform — the events fire only in an installable browser — so it may run unconditionally
|
|
* at startup, early enough to catch a prompt that fires before the CTA mounts.
|
|
*/
|
|
export function initInstallListeners(): void {
|
|
if (typeof window === 'undefined') return;
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
// Suppress Chromium's mini-infobar; the app presents its own CTA instead.
|
|
e.preventDefault();
|
|
install.deferred = e;
|
|
});
|
|
window.addEventListener('appinstalled', () => {
|
|
install.deferred = null;
|
|
install.installed = true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* promptInstall shows the native Chromium install dialog. It MUST be called directly from a user
|
|
* gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is
|
|
* single-use, so it is cleared here; a no-op when nothing was captured.
|
|
*/
|
|
export async function promptInstall(): Promise<void> {
|
|
const deferred = install.deferred;
|
|
if (!deferred) return;
|
|
install.deferred = null;
|
|
await deferred.prompt();
|
|
}
|
|
|
|
/**
|
|
* registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in
|
|
* the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini
|
|
* App. Best-effort — a failed registration only means the app is not installable.
|
|
*/
|
|
export function registerServiceWorker(): void {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
|
if (import.meta.env.MODE === 'mock') return;
|
|
if (inMiniApp()) return;
|
|
void navigator.serviceWorker.register('sw.js').catch(() => {});
|
|
}
|