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);
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,
+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 ---
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');
});