feat(pwa): install banner + update notification for service worker
- Install banner appears above header when beforeinstallprompt fires (dismissed state persisted in localStorage, never shows again) - Update banner appears when a new SW version is detected waiting (click "Update" → SKIP_WAITING message → SW activates → page reloads) - SW no longer auto-skipWaiting on install; waits for client message - Bumped SW cache version to v5 and registration to ?v=5 - Banner CSS: fixed at top, z-index above all chrome, slide-in animation - Header/tab-row/app offsets adjust when banner visible Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d5209021a
commit
0a21caa5e5
|
|
@ -290,6 +290,16 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<style>${TABBAR_CSS}</style>
|
<style>${TABBAR_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||||
|
<div class="rspace-banner rspace-banner--install" id="pwa-install-banner" style="display:none">
|
||||||
|
<span class="rspace-banner__text">Install rSpace app for the best experience</span>
|
||||||
|
<button class="rspace-banner__action" id="pwa-install-btn">Install</button>
|
||||||
|
<button class="rspace-banner__close" id="pwa-install-close" title="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="rspace-banner rspace-banner--update" id="pwa-update-banner" style="display:none">
|
||||||
|
<span class="rspace-banner__text">New version available</span>
|
||||||
|
<button class="rspace-banner__action" id="pwa-update-btn">Update</button>
|
||||||
|
<button class="rspace-banner__close" id="pwa-update-close" title="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
<header class="rstack-header">
|
<header class="rstack-header">
|
||||||
<div class="rstack-header__left">
|
<div class="rstack-header__left">
|
||||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||||
|
|
@ -343,16 +353,67 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import '/shell.js';
|
import '/shell.js';
|
||||||
// ── Service worker registration ──
|
// ── Service worker registration + update detection ──
|
||||||
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
||||||
navigator.serviceWorker.register("/sw.js?v=4").catch(() => {});
|
navigator.serviceWorker.register("/sw.js?v=5").then((reg) => {
|
||||||
|
function showUpdateBanner() {
|
||||||
|
const b = document.getElementById('pwa-update-banner');
|
||||||
|
if (!b || b.style.display !== 'none') return;
|
||||||
|
b.style.display = '';
|
||||||
|
document.body.classList.add('rspace-banner-visible');
|
||||||
|
}
|
||||||
|
if (reg.waiting) showUpdateBanner();
|
||||||
|
reg.addEventListener('updatefound', () => {
|
||||||
|
const nw = reg.installing;
|
||||||
|
if (!nw) return;
|
||||||
|
nw.addEventListener('statechange', () => {
|
||||||
|
if (nw.state === 'installed' && navigator.serviceWorker.controller) showUpdateBanner();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
let refreshing = false;
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// ── Install prompt capture ──
|
// ── PWA install banner ──
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
|
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
|
||||||
window.dispatchEvent(new CustomEvent("rspace-install-available"));
|
window.dispatchEvent(new CustomEvent("rspace-install-available"));
|
||||||
|
if (localStorage.getItem('rspace_install_dismissed') !== '1') {
|
||||||
|
const b = document.getElementById('pwa-install-banner');
|
||||||
|
if (b) { b.style.display = ''; document.body.classList.add('rspace-banner-visible'); }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
document.getElementById('pwa-install-btn')?.addEventListener('click', async () => {
|
||||||
|
if (window.__rspaceInstallPrompt) {
|
||||||
|
const choice = await window.__rspaceInstallPrompt();
|
||||||
|
if (choice?.outcome === 'accepted') {
|
||||||
|
document.getElementById('pwa-install-banner').style.display = 'none';
|
||||||
|
document.body.classList.remove('rspace-banner-visible');
|
||||||
|
localStorage.setItem('rspace_install_dismissed', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('pwa-install-close')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('pwa-install-banner').style.display = 'none';
|
||||||
|
document.body.classList.remove('rspace-banner-visible');
|
||||||
|
localStorage.setItem('rspace_install_dismissed', '1');
|
||||||
|
});
|
||||||
|
document.getElementById('pwa-update-btn')?.addEventListener('click', () => {
|
||||||
|
navigator.serviceWorker?.getRegistration().then((reg) => { reg?.waiting?.postMessage({ type: 'SKIP_WAITING' }); });
|
||||||
|
});
|
||||||
|
document.getElementById('pwa-update-close')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('pwa-update-banner').style.display = 'none';
|
||||||
|
document.body.classList.remove('rspace-banner-visible');
|
||||||
|
});
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
const ib = document.getElementById('pwa-install-banner');
|
||||||
|
if (ib) ib.style.display = 'none';
|
||||||
|
}
|
||||||
// ── Header minimize toggle ──
|
// ── Header minimize toggle ──
|
||||||
if (localStorage.getItem('rspace_headers_minimized') === '1') {
|
if (localStorage.getItem('rspace_headers_minimized') === '1') {
|
||||||
document.body.classList.add('rspace-headers-minimized');
|
document.body.classList.add('rspace-headers-minimized');
|
||||||
|
|
@ -2188,7 +2249,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import '/shell.js';
|
import '/shell.js';
|
||||||
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
||||||
navigator.serviceWorker.register("/sw.js?v=4").catch(() => {});
|
navigator.serviceWorker.register("/sw.js?v=5").catch(() => {});
|
||||||
}
|
}
|
||||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||||
try {
|
try {
|
||||||
|
|
@ -2523,7 +2584,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import '/shell.js';
|
import '/shell.js';
|
||||||
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
||||||
navigator.serviceWorker.register("/sw.js?v=4").catch(() => {});
|
navigator.serviceWorker.register("/sw.js?v=5").catch(() => {});
|
||||||
}
|
}
|
||||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,92 @@ body {
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PWA install / update banners ── */
|
||||||
|
|
||||||
|
.rspace-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 10002;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
animation: rspace-banner-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes rspace-banner-in {
|
||||||
|
from { transform: translateY(-100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.rspace-banner--install {
|
||||||
|
background: linear-gradient(135deg, #14b8a6, #0ea5e9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rspace-banner--update {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rspace-banner__text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rspace-banner__action {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.rspace-banner__action:hover {
|
||||||
|
background: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
.rspace-banner__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.rspace-banner__close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* Push header down when a banner is visible */
|
||||||
|
body.rspace-banner-visible .rstack-header {
|
||||||
|
top: 36px;
|
||||||
|
}
|
||||||
|
body.rspace-banner-visible .rstack-tab-row {
|
||||||
|
top: 92px; /* 36px banner + 56px header */
|
||||||
|
}
|
||||||
|
body.rspace-banner-visible #app {
|
||||||
|
padding-top: 128px; /* 36px banner + 92px normal */
|
||||||
|
}
|
||||||
|
body.rspace-banner-visible .rapp-subnav {
|
||||||
|
top: 129px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.rspace-banner {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
/* On mobile, header is sticky not fixed, so no offsets needed —
|
||||||
|
the banner sits in flow above the header */
|
||||||
|
body.rspace-banner-visible .rstack-header { top: 0; }
|
||||||
|
body.rspace-banner-visible .rstack-tab-row { top: 0; }
|
||||||
|
body.rspace-banner-visible #app { padding-top: 0; }
|
||||||
|
body.rspace-banner-visible .rapp-subnav { top: auto; }
|
||||||
|
.rspace-banner { position: sticky; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Header bar ── */
|
/* ── Header bar ── */
|
||||||
|
|
||||||
.rstack-header {
|
.rstack-header {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
const CACHE_VERSION = "rspace-v4";
|
const CACHE_VERSION = "rspace-v5";
|
||||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||||
|
|
@ -51,7 +51,8 @@ self.addEventListener("install", (event) => {
|
||||||
]);
|
]);
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
// Don't skipWaiting automatically — let the client trigger it via
|
||||||
|
// SKIP_WAITING message so the update banner can prompt the user first.
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
|
|
@ -292,6 +293,12 @@ self.addEventListener("message", (event) => {
|
||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (!msg || typeof msg !== "object") return;
|
if (!msg || typeof msg !== "object") return;
|
||||||
|
|
||||||
|
// Client requests this SW to activate immediately (user clicked "Update")
|
||||||
|
if (msg.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === "cache-ecosystem-module" && msg.moduleUrl) {
|
if (msg.type === "cache-ecosystem-module" && msg.moduleUrl) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue