From 28aaf3d484145da47623b74f6585a679ec3bd987 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sat, 4 Apr 2026 06:21:13 -0700 Subject: [PATCH] fix: restore favicon activity badge + add activity count and hostname to page title Favicon badge: removed crossOrigin='anonymous' from _drawFaviconBadge Image lazy-init. Same-origin favicons don't need CORS credentials; setting it caused silent cache-miss failures when the browser had the asset cached without CORS headers, preventing the canvas from drawing. Page title: new updatePageTitle() centralizes title logic. Format: '(N) hostname - muxplex' when N sessions have unseen bells, otherwise 'hostname - muxplex'. Hostname uses device_name setting with fallback to location.hostname. Called from pollSessions on every tick, from loadServerSettings callback, and immediately when device_name input changes (updating _serverSettings.device_name in place first so the helper sees the latest value without waiting for the debounced PATCH). Replaced 3 scattered document.title assignments with updatePageTitle(). Updated 3 existing source-pattern tests to reflect the new centralised approach; added 2 new tests for updatePageTitle existence and pollSessions call site. --- muxplex/frontend/app.js | 31 ++++++++++++++--- muxplex/frontend/tests/test_app.mjs | 53 +++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index e62b230..5780f3a 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -269,6 +269,7 @@ async function pollSessions() { handleBellTransitions(prev, sessions); updateSessionPill(sessions); updateFaviconBadge(); + updatePageTitle(); } catch (err) { _pollFailCount++; setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); @@ -1062,7 +1063,8 @@ function _drawFaviconBadge() { // Lazy-init: create the Image object once and cache it — subsequent calls reuse it if (!_faviconImage) { _faviconImage = new Image(); - _faviconImage.crossOrigin = 'anonymous'; + // No crossOrigin: favicon is same-origin; crossOrigin on same-origin images can + // cause cache misses when the browser has the asset cached without CORS headers. _faviconImage.src = _originalFavicon; } @@ -1123,6 +1125,24 @@ function updateFaviconBadge() { _drawFaviconBadge(); } +/** + * Update the page title with an optional activity count prefix and the hostname. + * Format: "(N) hostname - muxplex" when N sessions have unseen bells, otherwise + * "hostname - muxplex". Hostname is device_name from server settings, falling back + * to location.hostname so even unconfigured installs show something useful. + * Call from pollSessions() on every tick, and whenever server settings change. + */ +function updatePageTitle() { + var hostname = (_serverSettings && _serverSettings.device_name) || + (typeof location !== 'undefined' ? location.hostname : null) || + 'muxplex'; + var count = (_currentSessions || []).filter(function(s) { + return s.bell && s.bell.unseen_count > 0; + }).length; + var prefix = count > 0 ? '(' + count + ') ' : ''; + document.title = prefix + hostname + ' - muxplex'; +} + // ─── Session open / close ──────────────────────────────────────────────────── /** @@ -1691,7 +1711,7 @@ function openSettings() { } // Update document.title from device_name setting - document.title = (ss && ss.device_name) || 'muxplex'; + updatePageTitle(); // Multi-device enabled checkbox (with smart default: checked if remote_instances non-empty) const multiDeviceEnabledEl = $('setting-multi-device-enabled'); @@ -2235,7 +2255,9 @@ function bindStaticEventListeners() { on($('setting-device-name'), 'input', function() { clearTimeout(_deviceNameDebounceTimer); var val = this.value; - document.title = val || 'muxplex'; + // Update cached setting immediately so updatePageTitle() sees the new value + if (_serverSettings) _serverSettings.device_name = val; + updatePageTitle(); _deviceNameDebounceTimer = setTimeout(function() { patchServerSetting('device_name', val); }, 500); @@ -2378,7 +2400,7 @@ document.addEventListener('DOMContentLoaded', () => { .then(() => { startPolling(); loadServerSettings().then(function() { - document.title = _serverSettings.device_name || 'muxplex'; + updatePageTitle(); }); startHeartbeat(); bindStaticEventListeners(); @@ -2428,6 +2450,7 @@ if (typeof module !== 'undefined' && module.exports) { closeBottomSheet, renderSheetList, updateSessionPill, + updatePageTitle, // ANSI color rendering ansiToHtml, ansiParamsToStyle, diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index fb44ad3..8213b36 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -3125,29 +3125,42 @@ test('CSS style.css uses base position:absolute bottom:0 for fit mode content an // --- document.title quality fixes --- -test('device name input handler restores default title when value is cleared', () => { +test('device name input handler updates title via updatePageTitle when value is cleared', () => { const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); - // Must use fallback form, not the conditional-only form + // Handler now calls updatePageTitle() (which uses location.hostname fallback) instead of + // directly setting document.title. Verify the old direct-assignment is gone and the + // centralised helper is used. assert.ok( - source.includes("document.title = val || 'muxplex'"), - "device name input handler must set document.title = val || 'muxplex' to restore default when cleared" + !source.includes("document.title = val || 'muxplex'"), + "device name input handler must NOT directly set document.title = val || 'muxplex' (use updatePageTitle)" ); assert.ok( !source.includes("if (val) document.title = val"), "device name input handler must NOT use conditional 'if (val) document.title = val' (skips restore)" ); + // The handler must update _serverSettings.device_name before calling updatePageTitle() + assert.ok( + source.includes('_serverSettings.device_name = val'), + "device name input handler must update _serverSettings.device_name before calling updatePageTitle()" + ); }); -test('openSettings restores default title unconditionally when device_name is absent', () => { +test('openSettings updates title via updatePageTitle (not direct assignment)', () => { const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + // The old direct assignment is replaced by updatePageTitle() in the settings panel loader assert.ok( - source.includes("document.title = (ss && ss.device_name) || 'muxplex'"), - "openSettings must set document.title = (ss && ss.device_name) || 'muxplex' unconditionally" + !source.includes("document.title = (ss && ss.device_name) || 'muxplex'"), + "openSettings must NOT directly set document.title — use updatePageTitle() instead" ); assert.ok( !source.includes("if (ss && ss.device_name) {\n document.title = ss.device_name;\n }"), "openSettings must NOT use conditional block that skips restore when device_name is absent" ); + // The settings loader area (where it updates device_name field) must call updatePageTitle + assert.ok( + source.includes('updatePageTitle'), + "app.js must define and use updatePageTitle for title management" + ); }); test('remote instance event listener comments say Multi-Device tab not Sessions tab', () => { @@ -3170,15 +3183,20 @@ test('remote instance event listener comments say Multi-Device tab not Sessions ); }); -test('DOMContentLoaded sets document.title from server settings at page load', () => { +test('DOMContentLoaded sets page title via updatePageTitle after loadServerSettings resolves', () => { const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); // Find the DOMContentLoaded block const domIdx = source.indexOf("document.addEventListener('DOMContentLoaded'"); assert.ok(domIdx !== -1, 'DOMContentLoaded handler must exist'); const domBlock = source.substring(domIdx, domIdx + 800); + // The old direct assignment is replaced by updatePageTitle() assert.ok( - domBlock.includes("document.title = _serverSettings.device_name || 'muxplex'"), - "DOMContentLoaded must set document.title = _serverSettings.device_name || 'muxplex' after loadServerSettings resolves" + !domBlock.includes("document.title = _serverSettings.device_name || 'muxplex'"), + "DOMContentLoaded must NOT directly set document.title — delegate to updatePageTitle()" + ); + assert.ok( + domBlock.includes("updatePageTitle"), + "DOMContentLoaded must call updatePageTitle() after loadServerSettings resolves" ); }); @@ -4070,3 +4088,18 @@ test('closeSession after openSession with remoteId=0 does NOT fire DELETE /api/s globalThis.document.querySelector = origQS; globalThis.setTimeout = origSetTimeout; }); + +test('updatePageTitle function exists and uses activity count', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + assert.ok(source.includes('function updatePageTitle'), 'must have updatePageTitle function'); + assert.ok(source.includes('unseen_count'), 'must count unseen bells for title'); + assert.ok(source.includes('document.title'), 'must set document.title'); + assert.ok(source.includes('location.hostname'), 'must fall back to location.hostname'); +}); + +test('updatePageTitle is called from pollSessions', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + // Use 700 chars — the call is ~562 chars into the function after comments/whitespace + const pollFn = source.substring(source.indexOf('async function pollSessions'), source.indexOf('async function pollSessions') + 700); + assert.ok(pollFn.includes('updatePageTitle'), 'pollSessions must call updatePageTitle'); +});