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:
Jeff Emmett 2026-03-31 10:40:07 -07:00
parent 7d5209021a
commit 0a21caa5e5
3 changed files with 161 additions and 7 deletions

View File

@ -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">&times;</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">&times;</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 {

View File

@ -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 {

View File

@ -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 () => {