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>
|
||||
</head>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -343,16 +353,67 @@ export function renderShell(opts: ShellOptions): string {
|
|||
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
// ── Service worker registration ──
|
||||
// ── Service worker registration + update detection ──
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
window.__rspaceInstallPrompt = () => { e.prompt(); return e.userChoice; };
|
||||
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 ──
|
||||
if (localStorage.getItem('rspace_headers_minimized') === '1') {
|
||||
document.body.classList.add('rspace-headers-minimized');
|
||||
|
|
@ -2188,7 +2249,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
|||
<script type="module">
|
||||
import '/shell.js';
|
||||
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});
|
||||
try {
|
||||
|
|
@ -2523,7 +2584,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
|
|||
<script type="module">
|
||||
import '/shell.js';
|
||||
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});
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,92 @@ body {
|
|||
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 ── */
|
||||
|
||||
.rstack-header {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/// <reference lib="webworker" />
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const CACHE_VERSION = "rspace-v4";
|
||||
const CACHE_VERSION = "rspace-v5";
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||
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) => {
|
||||
|
|
@ -292,6 +293,12 @@ self.addEventListener("message", (event) => {
|
|||
const msg = event.data;
|
||||
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) {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue