diff --git a/app/public/sw.js b/app/public/sw.js
index c4ff9eb..a2f6b54 100644
--- a/app/public/sw.js
+++ b/app/public/sw.js
@@ -4,7 +4,7 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting();
});
-const CACHE = 'weather-v14';
+const CACHE = 'weather-v15';
// App shell cached on install for offline resilience.
const PRECACHE = [
@@ -101,14 +101,14 @@ self.addEventListener('fetch', event => {
}
return response;
})
- .catch(() => caches.match(request).then(cached => cached || caches.match('/')))
+ .catch(() => caches.match(request, { ignoreSearch: true }).then(cached => cached || caches.match('/', { ignoreSearch: true })))
);
return;
}
// Cache-first for static assets.
event.respondWith(
- caches.match(request).then(cached => {
+ caches.match(request, { ignoreSearch: true }).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
if (response.ok && (
diff --git a/app/server.js b/app/server.js
index 1adb4ca..77ff64f 100644
--- a/app/server.js
+++ b/app/server.js
@@ -619,6 +619,63 @@ function isValidCoord(value, min, max) {
return Number.isFinite(n) && n >= min && n <= max;
}
+// ── Cache-proof asset URLs ───────────────────────────────────────────────────
+// A reverse proxy in front of the app was serving stale copies of static files (old app.js / sw.js)
+// next to new HTML. HTML is always fetched fresh, so every local script, stylesheet, icon and manifest
+// it references gets a content-hash version (app.js?v=ab12cd34): any change is a brand-new URL that no
+// proxy or phone has cached.
+const PUBLIC_DIR = path.join(__dirname, 'public');
+const assetHashes = new Map(); // absolute path -> { mtimeMs, hash }
+function assetVersion(url) {
+ const rel = url.replace(/^\//, '');
+ const abs = path.join(PUBLIC_DIR, rel);
+ if (!abs.startsWith(PUBLIC_DIR)) return null;
+ try {
+ const st = fs.statSync(abs);
+ const hit = assetHashes.get(abs);
+ if (hit && hit.mtimeMs === st.mtimeMs) return hit.hash;
+ const hash = crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex').slice(0, 10);
+ assetHashes.set(abs, { mtimeMs: st.mtimeMs, hash });
+ return hash;
+ } catch { return null; }
+}
+const inlineIcons = new Map();
+function inlineIcon(url) {
+ const abs = path.join(PUBLIC_DIR, url.replace(/^\//, ''));
+ if (!abs.startsWith(PUBLIC_DIR)) return null;
+ try {
+ const st = fs.statSync(abs);
+ const hit = inlineIcons.get(abs);
+ if (hit && hit.mtimeMs === st.mtimeMs) return hit.uri;
+ const uri = 'data:image/png;base64,' + fs.readFileSync(abs).toString('base64');
+ inlineIcons.set(abs, { mtimeMs: st.mtimeMs, uri });
+ return uri;
+ } catch { return null; }
+}
+function versionedHtml(file) {
+ let html = fs.readFileSync(path.join(PUBLIC_DIR, file), 'utf8');
+ html = html.replace(/\b(src|href)="([^"#?]+\.(?:js|css|png|svg|webmanifest|json))"/g, (m, attr, url) => {
+ if (/^(?:[a-z]+:)?\/\//i.test(url)) return m; // external (https://, //cdn)
+ const v = assetVersion(url);
+ return v ? `${attr}="${url}?v=${v}"` : m;
+ });
+ // iOS draws a letter tile if its separate icon download fails (proxy, network path, cached failure).
+ // Embed the 180px touch icon in the page itself so the image arrives with the HTML, no second request.
+ html = html.replace(//, (m, url) => {
+ const uri = inlineIcon(url);
+ return uri ? `` : m;
+ });
+ // the service worker script is cached by the same proxy, so version its URL too
+ const swV = assetVersion('/sw.js');
+ if (swV) html = html.replace(/register\('\/sw\.js'\)/g, `register('/sw.js?v=${swV}')`);
+ return html;
+}
+function sendHtml(res, file) {
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ res.send(versionedHtml(file));
+}
+
// Keep shell assets fresh so SW/app versions do not drift.
app.use('/', (req, res, next) => {
if (req.path === '/' || req.path.endsWith('.html')) {
@@ -634,6 +691,7 @@ app.use('/', (req, res, next) => {
next();
});
+app.get(['/', '/index.html'], (_req, res) => sendHtml(res, 'index.html'));
app.use(express.static(path.join(__dirname, 'public')));
// Prevent browser favicon requests from showing noisy 404s.
@@ -1430,7 +1488,7 @@ app.use('/dashboard', express.urlencoded({ extended: false }));
// Setup (first-time account creation)
app.get('/dashboard/setup', (req, res) => {
if (hasUsers()) return res.redirect('/dashboard');
- res.sendFile(path.join(__dirname, 'public', 'dashboard-setup.html'));
+ sendHtml(res, 'dashboard-setup.html');
});
// First-run setup creates the initial admin account
@@ -1453,7 +1511,7 @@ app.post('/dashboard/setup', (req, res) => {
app.get('/dashboard/login', (req, res) => {
if (!hasUsers()) return res.redirect('/dashboard/setup');
if (currentUser(req)) return res.redirect('/dashboard');
- res.sendFile(path.join(__dirname, 'public', 'dashboard-login.html'));
+ sendHtml(res, 'dashboard-login.html');
});
app.post('/dashboard/login', (req, res) => {
@@ -1481,7 +1539,7 @@ app.get('/dashboard/logout', (_req, res) => {
// Password recovery: the daily token (printed in the server log) resets the primary admin account
app.get('/dashboard/recover', (_req, res) => {
if (!hasUsers()) return res.redirect('/dashboard/setup');
- res.sendFile(path.join(__dirname, 'public', 'dashboard-recover.html'));
+ sendHtml(res, 'dashboard-recover.html');
});
app.post('/dashboard/recover', (req, res) => {
@@ -1504,16 +1562,16 @@ app.post('/dashboard/recover', (req, res) => {
// Main dashboard
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
+ sendHtml(res, 'dashboard.html');
});
// Settings page
// Account page: everyone can change their own password; admins also manage users here
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html'));
+ sendHtml(res, 'dashboard-account.html');
});
app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
+ sendHtml(res, 'dashboard-settings.html');
});
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for