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.
This commit is contained in:
Brian Krabach
2026-04-04 06:21:13 -07:00
parent c92689dab2
commit 28aaf3d484
2 changed files with 70 additions and 14 deletions
+27 -4
View File
@@ -269,6 +269,7 @@ async function pollSessions() {
handleBellTransitions(prev, sessions); handleBellTransitions(prev, sessions);
updateSessionPill(sessions); updateSessionPill(sessions);
updateFaviconBadge(); updateFaviconBadge();
updatePageTitle();
} catch (err) { } catch (err) {
_pollFailCount++; _pollFailCount++;
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); 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 // Lazy-init: create the Image object once and cache it — subsequent calls reuse it
if (!_faviconImage) { if (!_faviconImage) {
_faviconImage = new Image(); _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; _faviconImage.src = _originalFavicon;
} }
@@ -1123,6 +1125,24 @@ function updateFaviconBadge() {
_drawFaviconBadge(); _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 ──────────────────────────────────────────────────── // ─── Session open / close ────────────────────────────────────────────────────
/** /**
@@ -1691,7 +1711,7 @@ function openSettings() {
} }
// Update document.title from device_name setting // 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) // Multi-device enabled checkbox (with smart default: checked if remote_instances non-empty)
const multiDeviceEnabledEl = $('setting-multi-device-enabled'); const multiDeviceEnabledEl = $('setting-multi-device-enabled');
@@ -2235,7 +2255,9 @@ function bindStaticEventListeners() {
on($('setting-device-name'), 'input', function() { on($('setting-device-name'), 'input', function() {
clearTimeout(_deviceNameDebounceTimer); clearTimeout(_deviceNameDebounceTimer);
var val = this.value; 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() { _deviceNameDebounceTimer = setTimeout(function() {
patchServerSetting('device_name', val); patchServerSetting('device_name', val);
}, 500); }, 500);
@@ -2378,7 +2400,7 @@ document.addEventListener('DOMContentLoaded', () => {
.then(() => { .then(() => {
startPolling(); startPolling();
loadServerSettings().then(function() { loadServerSettings().then(function() {
document.title = _serverSettings.device_name || 'muxplex'; updatePageTitle();
}); });
startHeartbeat(); startHeartbeat();
bindStaticEventListeners(); bindStaticEventListeners();
@@ -2428,6 +2450,7 @@ if (typeof module !== 'undefined' && module.exports) {
closeBottomSheet, closeBottomSheet,
renderSheetList, renderSheetList,
updateSessionPill, updateSessionPill,
updatePageTitle,
// ANSI color rendering // ANSI color rendering
ansiToHtml, ansiToHtml,
ansiParamsToStyle, ansiParamsToStyle,
+43 -10
View File
@@ -3125,29 +3125,42 @@ test('CSS style.css uses base position:absolute bottom:0 for fit mode content an
// --- document.title quality fixes --- // --- 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'); 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( assert.ok(
source.includes("document.title = val || 'muxplex'"), !source.includes("document.title = val || 'muxplex'"),
"device name input handler must set document.title = val || 'muxplex' to restore default when cleared" "device name input handler must NOT directly set document.title = val || 'muxplex' (use updatePageTitle)"
); );
assert.ok( assert.ok(
!source.includes("if (val) document.title = val"), !source.includes("if (val) document.title = val"),
"device name input handler must NOT use conditional 'if (val) document.title = val' (skips restore)" "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'); 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( assert.ok(
source.includes("document.title = (ss && ss.device_name) || 'muxplex'"), !source.includes("document.title = (ss && ss.device_name) || 'muxplex'"),
"openSettings must set document.title = (ss && ss.device_name) || 'muxplex' unconditionally" "openSettings must NOT directly set document.title — use updatePageTitle() instead"
); );
assert.ok( assert.ok(
!source.includes("if (ss && ss.device_name) {\n document.title = ss.device_name;\n }"), !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" "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', () => { 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'); const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// Find the DOMContentLoaded block // Find the DOMContentLoaded block
const domIdx = source.indexOf("document.addEventListener('DOMContentLoaded'"); const domIdx = source.indexOf("document.addEventListener('DOMContentLoaded'");
assert.ok(domIdx !== -1, 'DOMContentLoaded handler must exist'); assert.ok(domIdx !== -1, 'DOMContentLoaded handler must exist');
const domBlock = source.substring(domIdx, domIdx + 800); const domBlock = source.substring(domIdx, domIdx + 800);
// The old direct assignment is replaced by updatePageTitle()
assert.ok( assert.ok(
domBlock.includes("document.title = _serverSettings.device_name || 'muxplex'"), !domBlock.includes("document.title = _serverSettings.device_name || 'muxplex'"),
"DOMContentLoaded must set document.title = _serverSettings.device_name || 'muxplex' after loadServerSettings resolves" "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.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout; 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');
});