`;
const toggle = card.querySelector('input[type="checkbox"]');
toggle.addEventListener('change', () => {
if (toggle.checked) enablePlugin(file); else disablePlugin(file);
});
container.appendChild(card);
}
}
// Themes. Members only see published files, plus one they already have on.
const listedThemes = [...loadedThemes].filter(([file, record]) => isListed(file, record));
if (loadedThemes.size === 0) {
themeContainer.innerHTML = '
No themes found. Drop .theme.css files into the themes/ folder.
`;
} else {
themeContainer.innerHTML = '';
for (const [file, themeRecord] of listedThemes) {
const card = document.createElement('div');
card.className = 'plugin-card';
const compatibilityLabel = themeCompatibilityLabel(themeRecord.meta);
const suppressedLabel = themeRecord.suppressed
? ` • ${escHtml(suppressExtensions
? t('settings.plugins_section.suppressed_safe_mode')
: t('settings.plugins_section.incompatible_theme'))}`
: '';
// Admins see which files only they can use; a member sees it on the
// one they kept, which they can turn off but not back on.
const publishLabel = themeRecord.meta.published
? ''
: ` • ${escHtml(t('settings.plugins_section.not_published'))}`;
const toggleDisabled = (!themeRecord.compatible || suppressExtensions || !isOffered(file)) && !themeRecord.enabled;
card.innerHTML = `
`;
const toggle = card.querySelector('input[type="checkbox"]');
toggle.addEventListener('change', () => {
if (toggle.checked) enableTheme(file); else disableTheme(file);
});
themeContainer.appendChild(card);
}
}
}
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ═══════════════════════════════════════════════════════
// Init: fetch & load all plugins and themes
// ═══════════════════════════════════════════════════════
async function fetchList(url) {
try {
const response = await fetch(url);
if (!response.ok) return null;
const value = await response.json();
return Array.isArray(value) ? value : null;
} catch {
return null;
}
}
async function init(forceThemeRefresh = false) {
try {
const [pluginRes, themeRes] = await Promise.all([
fetchList('/api/plugins'),
ThemeCompat?.fetchThemes?.(fetch, forceThemeRefresh) || fetchList('/api/themes'),
]);
const authoritativeThemes = Array.isArray(themeRes);
if (authoritativeThemes) {
const availableFiles = new Set(themeRes.map(theme => theme.file));
for (const [file, record] of loadedThemes) {
if (availableFiles.has(file)) continue;
record.linkEl?.remove();
loadedThemes.delete(file);
}
ThemeCompat?.cacheThemes?.(themeRes);
themeMetadataReady = true;
} else {
// A network or response error is not proof that a saved theme vanished.
// Keep the preference and show the Haven base until metadata can be retried.
document.documentElement.removeAttribute('data-haven-theme-pending');
}
// Load themes first. Safe mode records them without injecting CSS.
for (const t of themeRes || []) loadTheme(t);
// loadPlugin() only records metadata while extensions are suppressed, so
// no plugin file is fetched or evaluated in safe/recovery mode.
for (const p of pluginRes || []) await loadPlugin(p);
if (!suppressExtensions && authoritativeThemes) {
const requested = pendingFileTheme || (getActiveFileTheme()
? { file: getActiveFileTheme(), persist: false, syncFallback: false }
: null);
pendingFileTheme = null;
if (requested) {
const record = loadedThemes.get(requested.file);
if (record?.compatible) {
applyFileTheme(requested.file, requested.persist, requested.syncFallback);
}
else fallbackToHaven(requested.file, true, requested.syncFallback);
}
}
setupSafeModeUI();
renderPluginUI();
if (recoveryPending) {
const saved = await persistThemePreference('haven');
if (saved) {
recoveryPending = false;
clearResetPending();
clearSafeMode();
window.location.assign(urlWithoutSafeMode());
return;
}
HavenApi.UI.showToast(t('settings.plugins_section.reset_sync_pending'), 'warning');
}
console.log(`[Haven] Loaded ${loadedPlugins.size} plugin(s), ${loadedThemes.size} theme(s)${suppressExtensions ? ' (suppressed)' : ''}`);
// The Sidebar Layout picker in Settings lists layout plugins once they are in.
document.dispatchEvent(new CustomEvent('haven:plugins-loaded'));
} catch (err) {
console.warn('[Haven] Plugin/theme init error:', err);
}
}
// A theme picked in the theme picker. A full theme (published, or one
// that sets the palette) becomes the selected theme and is saved to the
// account, as a Settings toggle does. A theme that only adds CSS on top of
// the selected one is switched on or off instead.
function selectFileTheme(file) {
const t = loadedThemes.get(file);
if (!t || suppressExtensions || !t.compatible) return false;
if (!isOffered(file) && !t.enabled) return false;
if (!isExclusiveMeta({ ...t.meta, file })) {
if (t.enabled) disableTheme(file); else enableTheme(file);
return true;
}
if (t.enabled && getActiveFileTheme() === file) return true;
if (applyFileTheme(file)) void persistThemePreference(`file:${file}`);
return true;
}
/**
* Apply a .theme.css file as the active theme.
* Clears built-in theme vars, removes other injected theme links,
* and injects the chosen file's link.
* @param {string} file - e.g. "mytheme.theme.css"
* @param {boolean} [persist=true] - whether to save to localStorage
*/
function applyFileTheme(file, persist = true, syncFallback = true) {
if (!file || suppressExtensions) return false;
if (!themeMetadataReady) {
pendingFileTheme = { file, persist, syncFallback };
return false;
}
const selected = loadedThemes.get(file);
if (!selected || !selected.compatible) {
fallbackToHaven(file, true, syncFallback);
return false;
}
// Clear inline custom-theme vars (from the triangle editor / rgb cycle)
if (typeof clearCustomVars === 'function') clearCustomVars();
if (typeof stopRgbCycle === 'function') stopRgbCycle();
// Hide custom/rgb editors if open
document.getElementById('custom-theme-editor')?._hide?.();
document.getElementById('rgb-theme-editor')?._hide?.();
loadedThemes.forEach(t => {
t.enabled = false;
t.suppressed = false;
t.linkEl = null;
});
// The link injection, the 'haven' layout base, persistence and the button
// active state are shared with the login page, so they live in theme.js and
// are called from here rather than written out twice. Everything below this
// point is plugin-loader's own bookkeeping, which the login page has no use
// for. (#5537)
const linkEl = applyPublishedThemeBase(file, persist, selected.meta);
if (!linkEl) {
fallbackToHaven(file, true, syncFallback);
return false;
}
const t = loadedThemes.get(file);
if (t) { t.enabled = true; t.suppressed = false; t.linkEl = linkEl; }
// Selecting a published theme deselects any other one, so drop the others
// from the enabled list. Otherwise the Settings toggles claim a theme is
// on while the picker shows a different one as active.
const kept = getEnabledThemes().filter(f =>
f !== file && !isExclusiveTheme(f) && loadedThemes.get(f)?.compatible !== false
);
setEnabledThemes([file, ...kept]);
// Re-inject user-enabled custom CSS tweaks (non-published themes) on top of the new base.
reapplyEnabledThemes(file);
renderPluginUI();
// Built-in theme buttons re-run the effect layer on every switch; file themes
// have to do the same or the previous theme's overlays (FFX water, Matrix rain,
// Nord snow…) keep running on top of the new one. Guarded because theme.js
// isn't guaranteed to be loaded wherever the loader runs.
if (!suppressExtensions && typeof applyEffects === 'function') {
const fxMode = typeof _getStoredEffectMode === 'function' ? _getStoredEffectMode() : 'auto';
applyEffects(fxMode);
}
if (!suppressExtensions && typeof showEffectEditorIfDynamic === 'function') {
showEffectEditorIfDynamic(`file:${file}`);
}
document.documentElement.removeAttribute('data-haven-theme-pending');
return true;
}
// Re-inject the user-enabled CSS tweaks that are missing from the DOM.
// Called after any theme switch that removes haven-theme-* links.
// The active theme is skipped (it was just injected, and re-adding it would
// move it after the tweaks meant to override it) and so is every exclusive
// palette, which is a selectable theme rather than an overlay.
function dropExclusiveThemesFromEnabled(keepFile) {
const enabled = getEnabledThemes();
const kept = enabled.filter(f => f === keepFile || !isExclusiveTheme(f));
if (kept.length !== enabled.length) setEnabledThemes(kept);
for (const [file, t] of loadedThemes) {
if (file === keepFile) continue;
if (!isExclusiveTheme(file)) continue;
t.enabled = false;
t.linkEl = null;
}
}
function reapplyEnabledThemes(activeFile = getActiveFileTheme()) {
if (suppressExtensions) return;
dropExclusiveThemesFromEnabled(activeFile);
const enabledList = getEnabledThemes();
for (const file of enabledList) {
if (file === activeFile) continue;
if (isExclusiveTheme(file)) continue;
const t = loadedThemes.get(file);
if (!t?.compatible) continue;
if (document.getElementById(`haven-theme-${file}`)) continue; // already present
const linkEl = document.createElement('link');
linkEl.rel = 'stylesheet';
linkEl.href = `/themes/${encodeURIComponent(file)}?_=${Date.now()}`;
linkEl.id = `haven-theme-${file}`;
document.head.appendChild(linkEl);
if (t) { t.enabled = true; t.suppressed = false; t.linkEl = linkEl; }
}
}
// Start when the app is ready
if (document.readyState === 'complete') {
setTimeout(init, 500);
} else {
window.addEventListener('load', () => setTimeout(init, 500));
}
return {
loadedPlugins,
loadedThemes,
enablePlugin,
disablePlugin,
enableTheme,
disableTheme,
renderPluginUI,
applyFileTheme,
selectFileTheme,
onThemeRefused,
suppressed: suppressExtensions,
reapplyEnabledThemes,
refresh: () => init(true),
};
})();