feat: ensure ui automatically updates regardless of version number
Task #53 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -1704,14 +1704,9 @@ async function renderSettings() {
|
|||||||
const btn = $('refreshUiBtn');
|
const btn = $('refreshUiBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Refreshing…';
|
btn.textContent = 'Refreshing…';
|
||||||
try {
|
// hardReloadUI() deletes all caches and unregisters the SW so the
|
||||||
await Promise.all([
|
// next page load always fetches fresh assets from the network.
|
||||||
fetch('/', { cache: 'reload' }),
|
await hardReloadUI();
|
||||||
fetch('/app.js', { cache: 'reload' }),
|
|
||||||
fetch('/app.css', { cache: 'reload' }),
|
|
||||||
]);
|
|
||||||
} catch { /* network hiccup — reload anyway */ }
|
|
||||||
location.reload();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('resetAppBtn').addEventListener('click', () => {
|
$('resetAppBtn').addEventListener('click', () => {
|
||||||
@@ -2796,53 +2791,70 @@ function pollBuildTag() {
|
|||||||
// PWA — service worker registration and update banner (WEB mode only)
|
// PWA — service worker registration and update banner (WEB mode only)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Hard-reload helper: wipes every SW cache and unregisters the SW so the
|
||||||
|
// next page load fetches fresh assets from the network, not stale cache.
|
||||||
|
// Called from both the update dialog and the Settings → Force refresh UI button.
|
||||||
|
async function hardReloadUI() {
|
||||||
|
try {
|
||||||
|
// 1. Delete every named cache (app shell, thumbs, fonts, etc.)
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
|
}
|
||||||
|
// 2. Unregister every service worker registration so the browser
|
||||||
|
// fetches sw.js fresh on the next load rather than serving a
|
||||||
|
// cached copy that could re-populate the caches.
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(regs.map((r) => r.unregister()));
|
||||||
|
}
|
||||||
|
} catch { /* best effort — reload regardless */ }
|
||||||
|
// Hard navigation to the root forces the browser to fetch index.html from
|
||||||
|
// the network (no SW is registered any more to intercept it).
|
||||||
|
window.location.href = window.location.origin + '/';
|
||||||
|
}
|
||||||
|
|
||||||
let _updateBannerShown = false;
|
let _updateBannerShown = false;
|
||||||
function showUpdateBanner(waitingSW) {
|
function showUpdateBanner() {
|
||||||
// Only show the banner once per page load
|
// Only show the dialog once per page load
|
||||||
if (_updateBannerShown) return;
|
if (_updateBannerShown) return;
|
||||||
_updateBannerShown = true;
|
_updateBannerShown = true;
|
||||||
|
|
||||||
// Use a persistent, click-to-reload toast instead of the standard 2.2s one
|
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
|
||||||
const t = document.createElement('div');
|
const body = document.createElement('p');
|
||||||
t.className = 'toast toast-update';
|
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
|
||||||
t.innerHTML = '⬆ Update ready — <button class="toast-reload-btn">Reload now</button>';
|
showModal('⬆ Update available', body, [
|
||||||
const container = $('toastContainer');
|
{ label: 'Later', onClick: closeModal },
|
||||||
container.appendChild(t);
|
{ label: 'Refresh UI', primary: true, onClick: async () => {
|
||||||
t.querySelector('.toast-reload-btn').addEventListener('click', () => {
|
closeModal();
|
||||||
if (waitingSW) {
|
await hardReloadUI();
|
||||||
// Signal the *waiting* SW (not the active controller) to take over.
|
}},
|
||||||
// Once it does, controllerchange fires and we reload.
|
]);
|
||||||
waitingSW.postMessage({ type: 'SKIP_WAITING' });
|
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true });
|
|
||||||
} else {
|
|
||||||
// No waiting SW (e.g. build-tag or SW_UPDATE_AVAILABLE path) — just reload.
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerServiceWorker() {
|
async function registerServiceWorker() {
|
||||||
if (!WEB || !('serviceWorker' in navigator)) return;
|
if (!WEB || !('serviceWorker' in navigator)) return;
|
||||||
try {
|
try {
|
||||||
const reg = await navigator.serviceWorker.register('/sw.js');
|
// updateViaCache:'none' prevents the browser from serving a stale
|
||||||
|
// cached copy of sw.js — the server always returns it fresh (no-store).
|
||||||
|
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||||||
|
|
||||||
// If a new SW is already waiting (e.g. user refreshed after an update),
|
// If a new SW is already waiting (e.g. user refreshed after an update),
|
||||||
// show the banner right away.
|
// show the dialog right away.
|
||||||
if (reg.waiting) { showUpdateBanner(reg.waiting); return; }
|
if (reg.waiting) { showUpdateBanner(); return; }
|
||||||
|
|
||||||
// Listen for a new SW installing after the page is open.
|
// Listen for a new SW installing after the page is open.
|
||||||
reg.addEventListener('updatefound', () => {
|
reg.addEventListener('updatefound', () => {
|
||||||
const sw = reg.installing;
|
const sw = reg.installing;
|
||||||
if (!sw) return;
|
if (!sw) return;
|
||||||
sw.addEventListener('statechange', () => {
|
sw.addEventListener('statechange', () => {
|
||||||
if (sw.state === 'installed' && reg.waiting) showUpdateBanner(reg.waiting);
|
if (sw.state === 'installed' && reg.waiting) showUpdateBanner();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
|
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
|
||||||
// In this case the new SW is already active, so just reload directly.
|
|
||||||
navigator.serviceWorker.addEventListener('message', (e) => {
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
||||||
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner(null);
|
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for updates in the background (useful for long-lived sessions)
|
// Check for updates in the background (useful for long-lived sessions)
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
* 5. SW calls skipWaiting() → takes over → client reloads.
|
* 5. SW calls skipWaiting() → takes over → client reloads.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
const VERSION = 'v1.0.3'; // ← bump this on every deploy to bust the cache
|
// BUILD_TAG is injected by the server at request time (GET /sw.js).
|
||||||
|
// It changes on every deploy/restart so the cache is busted automatically
|
||||||
|
// without any manual version bump.
|
||||||
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.3';
|
||||||
const CACHE = 'ytplayer-' + VERSION;
|
const CACHE = 'ytplayer-' + VERSION;
|
||||||
|
|
||||||
// Files that form the installable app shell.
|
// Files that form the installable app shell.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { serveStatic } from 'hono/bun';
|
|||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';
|
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
@@ -333,6 +334,35 @@ app.get('/api/user/data', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET /sw.js — serve the service worker with BUILD_TAG injected
|
||||||
|
//
|
||||||
|
// The raw sw.js file contains the placeholder `__BUILD_TAG__` which is
|
||||||
|
// replaced here with the actual BUILD_TAG string so the SW's cache name
|
||||||
|
// tracks the deployment automatically — no manual version bump needed.
|
||||||
|
// Served with no-store cache headers so browsers always re-fetch it and
|
||||||
|
// pick up the substituted value rather than a browser-cached stale copy.
|
||||||
|
// ============================================================================
|
||||||
|
let _swSource = null;
|
||||||
|
app.get('/sw.js', (c) => {
|
||||||
|
if (!_swSource) {
|
||||||
|
try {
|
||||||
|
_swSource = readFileSync('./public/sw.js', 'utf8');
|
||||||
|
} catch {
|
||||||
|
return c.text('Service worker not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inject the build tag: replace the placeholder with the real value.
|
||||||
|
const src = _swSource.replace(
|
||||||
|
"typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.3'",
|
||||||
|
JSON.stringify(BUILD_TAG)
|
||||||
|
);
|
||||||
|
return c.text(src, 200, {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Static files — serve the frontend/public directory
|
// Static files — serve the frontend/public directory
|
||||||
// Must come AFTER all /api routes so API takes priority
|
// Must come AFTER all /api routes so API takes priority
|
||||||
|
|||||||
Reference in New Issue
Block a user