feat(pwa): installable web app + landing web entry
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
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.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// 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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user