/** * Haven Plugin & Theme Loader * * Plugins: Drop .plugin.js files into Haven/plugins/ * Themes: Drop .theme.css files into Haven/themes/ * * Plugin format: * /** @name MyPlugin @description Does things @author Me @version 1.0 *​/ * class MyPlugin { * start() { /* called when enabled *​/ } * stop() { /* called when disabled *​/ } * } * * Theme format: * /** @name MyTheme @description Dark neon @author Me @version 1.0 *​/ * :root { --bg-primary: #000; ... } */ window.HavenPluginLoader = (function () { 'use strict'; // ═══════════════════════════════════════════════════════ // HavenApi: exposed to plugins as window.HavenApi // ═══════════════════════════════════════════════════════ const HavenApi = { // ── DOM helpers ── DOM: { /** Add a CSS class string to */ addStyle(id, css) { this.removeStyle(id); const el = document.createElement('style'); el.id = `haven-plugin-style-${id}`; el.textContent = css; document.head.appendChild(el); return el; }, removeStyle(id) { document.getElementById(`haven-plugin-style-${id}`)?.remove(); }, /** Query inside the app */ query(sel) { return document.querySelector(sel); }, queryAll(sel) { return [...document.querySelectorAll(sel)]; }, }, // Structural layout plugins are mutually exclusive. Acquiring the shared // owner before moving regions prevents two reversible plugins from saving // each other's temporary DOM as their native restore point. Layout: { _owner: null, _reservedOwner: null, acquire(owner) { const id = String(owner || '').trim(); if (!id || (this._reservedOwner && this._reservedOwner !== id) || (this._owner && this._owner !== id)) return false; if (this._owner === id) return true; this._owner = id; document.documentElement.setAttribute('data-haven-layout-owner', id); document.dispatchEvent(new CustomEvent('haven:layout-owner-change', { detail: { owner: id } })); return true; }, release(owner) { if (this._owner !== String(owner || '').trim()) return false; this._owner = null; document.documentElement.removeAttribute('data-haven-layout-owner'); document.dispatchEvent(new CustomEvent('haven:layout-owner-change', { detail: { owner: null } })); return true; }, _reserve(owner) { this._reservedOwner = String(owner || '').trim() || null; }, _clearReservation(owner) { const id = String(owner || '').trim() || null; if (this._reservedOwner !== id) return false; this._reservedOwner = null; return true; }, get owner() { return this._owner; }, }, // ── Data (localStorage wrapper) ── Data: { save(pluginName, key, value) { const store = JSON.parse(localStorage.getItem('haven_plugin_data') || '{}'); if (!store[pluginName]) store[pluginName] = {}; store[pluginName][key] = value; localStorage.setItem('haven_plugin_data', JSON.stringify(store)); }, load(pluginName, key, fallback = null) { const store = JSON.parse(localStorage.getItem('haven_plugin_data') || '{}'); return store[pluginName]?.[key] ?? fallback; }, delete(pluginName, key) { const store = JSON.parse(localStorage.getItem('haven_plugin_data') || '{}'); if (store[pluginName]) { delete store[pluginName][key]; } localStorage.setItem('haven_plugin_data', JSON.stringify(store)); }, }, // ── UI helpers ── UI: { showToast(message, type = 'info') { if (window.app && window.app._showToast) { window.app._showToast(message, type); } }, /** Show a simple confirm dialog; returns a Promise */ confirm(title, message) { return new Promise(resolve => { const result = window.confirm(`${title}\n\n${message}`); resolve(result); }); }, }, // ── Patcher: monkey-patch methods reversibly ── Patcher: { _patches: new Map(), before(id, obj, method, fn) { return this._patch(id, obj, method, fn, 'before'); }, after(id, obj, method, fn) { return this._patch(id, obj, method, fn, 'after'); }, instead(id, obj, method, fn) { return this._patch(id, obj, method, fn, 'instead'); }, _patch(id, obj, method, fn, type) { const original = obj[method]; if (typeof original !== 'function') return; const patchKey = `${id}::${method}`; if (!this._patches.has(patchKey)) { this._patches.set(patchKey, { original, obj, method, hooks: [] }); } const entry = this._patches.get(patchKey); entry.hooks.push({ type, fn, id }); obj[method] = function (...args) { let result; // Run 'before' hooks for (const h of entry.hooks) { if (h.type === 'before') h.fn.call(this, args); } // Run 'instead' or original const insteadHook = entry.hooks.find(h => h.type === 'instead'); if (insteadHook) { result = insteadHook.fn.call(this, args, entry.original.bind(this)); } else { result = entry.original.apply(this, args); } // Run 'after' hooks for (const h of entry.hooks) { if (h.type === 'after') { const r = h.fn.call(this, args, result); if (r !== undefined) result = r; } } return result; }; return () => this.unpatchAll(id); }, unpatchAll(id) { for (const [key, entry] of this._patches) { entry.hooks = entry.hooks.filter(h => h.id !== id); if (entry.hooks.length === 0) { entry.obj[entry.method] = entry.original; this._patches.delete(key); } } } }, // ── Socket access ── get socket() { return window.app?.socket || null; }, // ── Current user ── get currentUser() { return window.app?.user || null; }, // ── Channels ── get channels() { return window.app?.channels || []; }, // ── Current channel ── get currentChannel() { return window.app?.currentChannel || null; }, }; window.HavenApi = HavenApi; // ═══════════════════════════════════════════════════════ // Plugin Manager // ═══════════════════════════════════════════════════════ const loadedPlugins = new Map(); // name → { instance, meta, enabled } const loadedThemes = new Map(); // name → { meta, enabled, linkEl } const ThemeCompat = window.HavenThemeCompat; function detectSafeMode() { if (ThemeCompat?.isSafeMode?.(window.location) === true) return true; let requested = null; try { requested = new URLSearchParams(window.location.search).get('haven-safe-mode'); } catch { /* unreadable address: not in safe mode through the URL */ } try { if (requested === '1') sessionStorage.setItem('haven_safe_mode', '1'); if (requested === '0') sessionStorage.removeItem('haven_safe_mode'); if (requested === '0') return false; return requested === '1' || document.documentElement.hasAttribute('data-haven-safe-mode') || sessionStorage.getItem('haven_safe_mode') === '1'; } catch { return requested === '1' || document.documentElement.hasAttribute('data-haven-safe-mode'); } } const safeMode = detectSafeMode(); function detectRecoveryPending() { if (ThemeCompat?.isResetPending?.() === true) return true; try { return sessionStorage.getItem('haven_customizations_reset_pending') === '1'; } catch { return false; } } function clearSafeMode() { ThemeCompat?.clearSafeMode?.(); try { sessionStorage.removeItem('haven_safe_mode'); } catch { /* storage blocked: nothing to clear */ } } function clearResetPending() { ThemeCompat?.clearResetPending?.(); try { sessionStorage.removeItem('haven_customizations_reset_pending'); } catch { /* storage blocked: nothing to clear */ } } function resetLocalCustomizations() { try { localStorage.setItem('haven_theme', 'haven'); localStorage.setItem('haven_enabled_themes', '[]'); localStorage.setItem('haven_enabled_plugins', '[]'); sessionStorage.setItem('haven_customizations_reset_pending', '1'); } catch (err) { console.warn('[Safe mode] could not reset local customizations', err); } } function urlWithoutSafeMode() { const fromHelper = ThemeCompat?.urlWithoutSafeMode?.(window.location); if (fromHelper) return fromHelper; try { const url = new URL(window.location.href); url.searchParams.delete('haven-safe-mode'); return `${url.pathname}${url.search}${url.hash}`; } catch { return '/app.html'; } } let recoveryPending = detectRecoveryPending(); const suppressExtensions = safeMode || recoveryPending; let themeMetadataReady = false; let pendingFileTheme = null; function storedList(key) { try { const value = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []; } catch { return []; } } function getEnabledPlugins() { return storedList('haven_enabled_plugins'); } function setEnabledPlugins(list) { localStorage.setItem('haven_enabled_plugins', JSON.stringify(list)); } function getEnabledThemes() { return storedList('haven_enabled_themes'); } function setEnabledThemes(list) { localStorage.setItem('haven_enabled_themes', JSON.stringify(list)); } // Published themes are what the theme picker offers, so only one can be // active at a time. Palettes (Braid, Compact, anything that sets // --bg-primary) are the same even when an admin has not published them: // they replace the built-in tokens, they do not stack on Matrix. // Unpublished files without a palette stay additive CSS tweaks. function isExclusiveMeta(meta) { if (!meta) return false; if (meta.published || meta.palette) return true; if (meta.layout) return true; return /^(braid|braid-light|compact)\.theme\.css$/i.test(meta.file || ''); } function isExclusiveTheme(file) { const t = loadedThemes.get(file); return isExclusiveMeta(t ? { ...t.meta, file } : { file }); } function getActiveFileTheme() { const saved = localStorage.getItem('haven_theme') || ''; return saved.startsWith('file:') ? saved.slice(5) : null; } function isThemeCompatible(meta) { return !!meta && meta.compatible !== false && meta.compatibility !== 'invalid' && meta.compatibility !== 'unsupported'; } // Members use Haven's own themes and the theme files an admin published. // People who manage the server also get the rest, to try them and publish // them. Until the app knows who is signed in, nobody does. The server makes // the same check before it saves a theme file to an account. function canUseUnpublished() { return window.app?._canPublishThemes?.() === true; } function isOffered(file) { const t = loadedThemes.get(file); return !!t && (t.meta?.published === true || canUseUnpublished()); } // Settings lists what the viewer may use, plus an unpublished file they // already have on, so they keep it and can still turn it off. function isListed(file, record) { return isOffered(file) || record.enabled || getActiveFileTheme() === file; } function registerSuppressedPlugin(meta) { if (loadedPlugins.has(meta.file)) return; loadedPlugins.set(meta.file, { instance: null, meta, enabled: getEnabledPlugins().includes(meta.file), suppressed: true, }); } // ── Load a single plugin ── async function loadPlugin(meta) { if (loadedPlugins.has(meta.file)) return; if (suppressExtensions) { registerSuppressedPlugin(meta); return; } try { const resp = await fetch(`/plugins/${meta.file}?_=${Date.now()}`); const code = await resp.text(); // Execute in a Function scope so plugins can define classes // Pass globalThis as _win so plugins can register classes via _win.ClassName = ... const factory = new Function('HavenApi', '_win', code + '\n;return (typeof module !== "undefined" && module.exports) || (typeof exports !== "undefined" ? exports : null);'); const exported = factory(HavenApi, globalThis); // The plugin should place its class on window, or we find the last class defined // Convention: plugin sets module.exports = ClassName or _win.PluginName = class { ... } // We'll look for any new class on window that has start()/stop() let PluginClass = null; if (exported && typeof exported === 'function' && exported.prototype.start) { PluginClass = exported; } else { // Try to find a class whose name matches the file const baseName = meta.file.replace('.plugin.js', ''); if (window[baseName] && typeof window[baseName] === 'function') { PluginClass = window[baseName]; } else { // Fallback: look for any class defined via the code (we wrap it) // The code itself may call _win.XYZ = class { ... } // Just re-execute looking for the return value const fn2 = new Function('HavenApi', '_win', code + '\n;return typeof start === "function" ? { start, stop: typeof stop === "function" ? stop : () => {} } : null;'); const obj = fn2(HavenApi, globalThis); if (obj) PluginClass = function() { this.start = obj.start; this.stop = obj.stop || (() => {}); }; } } if (!PluginClass) { console.warn(`[Haven Plugins] Could not find plugin class in ${meta.file}`); return; } const instance = new PluginClass(); const enabled = getEnabledPlugins().includes(meta.file); loadedPlugins.set(meta.file, { instance, meta, enabled }); if (enabled) { try { instance.start(); } catch (err) { console.error(`[Plugin ${meta.name}] start() error:`, err); } } } catch (err) { console.error(`[Haven Plugins] Failed to load ${meta.file}:`, err); } } // ── Enable / disable a plugin ── function enablePlugin(file) { const p = loadedPlugins.get(file); if (!p || p.enabled || suppressExtensions || !p.instance) return; p.enabled = true; try { p.instance.start(); } catch (err) { console.error(`[Plugin ${p.meta.name}] start() error:`, err); } const list = getEnabledPlugins(); if (!list.includes(file)) { list.push(file); setEnabledPlugins(list); } renderPluginUI(); } function disablePlugin(file) { const p = loadedPlugins.get(file); if (!p || !p.enabled) return; p.enabled = false; if (p.instance) { try { p.instance.stop(); HavenApi.Patcher.unpatchAll(p.meta.name || file); HavenApi.DOM.removeStyle(p.meta.name || file); } catch (err) { console.error(`[Plugin ${p.meta.name}] stop() error:`, err); } } const list = getEnabledPlugins().filter(f => f !== file); setEnabledPlugins(list); renderPluginUI(); } // ── Load a theme ── function loadTheme(meta) { const compatible = isThemeCompatible(meta); const existing = loadedThemes.get(meta.file); if (existing) { existing.meta = meta; existing.compatible = compatible; existing.suppressed = existing.enabled && (!compatible || suppressExtensions); const shouldInject = existing.enabled && compatible && !suppressExtensions && (!isExclusiveMeta({ ...meta, file: meta.file }) || getActiveFileTheme() === meta.file); if (!shouldInject && existing.linkEl) { existing.linkEl.remove(); existing.linkEl = null; } if (shouldInject && !existing.linkEl) { const linkEl = document.createElement('link'); linkEl.rel = 'stylesheet'; linkEl.href = `/themes/${encodeURIComponent(meta.file)}?_=${Date.now()}`; linkEl.id = `haven-theme-${meta.file}`; document.head.appendChild(linkEl); existing.linkEl = linkEl; } return; } // A published or palette theme is only on when it is the selected one; // leaving a previously-selected one in the enabled list must not stack it // on top of whatever theme is active now, or tick its Settings toggle. const configured = getEnabledThemes().includes(meta.file) && (!isExclusiveMeta({ ...meta, file: meta.file }) || getActiveFileTheme() === meta.file); const shouldInject = configured && compatible && !suppressExtensions; let linkEl = null; if (shouldInject) { linkEl = document.createElement('link'); linkEl.rel = 'stylesheet'; linkEl.href = `/themes/${encodeURIComponent(meta.file)}?_=${Date.now()}`; linkEl.id = `haven-theme-${meta.file}`; document.head.appendChild(linkEl); } loadedThemes.set(meta.file, { meta, enabled: configured, compatible, suppressed: configured && (!compatible || suppressExtensions), linkEl, }); } function enableTheme(file) { const t = loadedThemes.get(file); if (!t || t.enabled || suppressExtensions || !t.compatible) return; if (!isOffered(file)) return; // A published theme is one of the picker's choices, not a stackable tweak: // turning it on means selecting it, so the two surfaces stay in agreement. // The account's saved theme has to follow, the same as a picker click, // or the server's older choice replaces this one on the next load. if (isExclusiveMeta({ ...t.meta, file })) { if (applyFileTheme(file)) void persistThemePreference(`file:${file}`); return; } t.enabled = true; const linkEl = document.createElement('link'); linkEl.rel = 'stylesheet'; linkEl.href = `/themes/${file}?_=${Date.now()}`; linkEl.id = `haven-theme-${file}`; document.head.appendChild(linkEl); t.linkEl = linkEl; const list = getEnabledThemes(); if (!list.includes(file)) { list.push(file); setEnabledThemes(list); } renderPluginUI(); } function disableTheme(file) { const t = loadedThemes.get(file); if (!t || !t.enabled) return; t.enabled = false; if (t.linkEl) { t.linkEl.remove(); t.linkEl = null; } t.suppressed = false; document.getElementById(`haven-theme-${file}`)?.remove(); const list = getEnabledThemes().filter(f => f !== file); setEnabledThemes(list); // Turning off the selected published theme deselects it, which means falling // back to the built-in default rather than leaving the picker pointing at a // stylesheet that is no longer loaded. if (isExclusiveMeta({ ...t.meta, file }) && getActiveFileTheme() === file) { selectBuiltinTheme('haven'); } renderPluginUI(); } // Switch back to a built-in data-theme, mirroring what a theme-picker button // does in theme.js (which isn't reachable from here as a function). function selectBuiltinTheme(theme) { document.documentElement.setAttribute('data-theme', theme); document.documentElement.removeAttribute('data-haven-theme-pending'); localStorage.setItem('haven_theme', theme); document.querySelectorAll('.theme-btn').forEach(b => { b.classList.toggle('active', b.dataset.theme === theme); }); if (window.havenSocket && window.havenSocket.connected) { window.havenSocket.emit('set-preference', { key: 'theme', value: theme }); } if (!suppressExtensions && typeof applyEffects === 'function') { applyEffects(typeof _getStoredEffectMode === 'function' ? _getStoredEffectMode() : 'auto'); } if (!suppressExtensions && typeof showEffectEditorIfDynamic === 'function') showEffectEditorIfDynamic(theme); } function persistThemePreference(theme) { return new Promise(resolve => { const socket = window.havenSocket; if (!socket) return resolve(false); let settled = false; let send; let connectionTimer = null; let saveTimer = null; const finish = value => { if (settled) return; settled = true; if (connectionTimer) clearTimeout(connectionTimer); if (saveTimer) clearTimeout(saveTimer); socket.off?.('preference-saved', onSaved); socket.off?.('preference-refused', onRefused); if (send) socket.off?.('connect', send); resolve(value); }; const onSaved = data => { if (data?.key === 'theme' && data.value === theme) finish(true); }; const onRefused = data => { if (data?.key === 'theme' && data.value === theme) finish(false); }; send = () => { if (connectionTimer) clearTimeout(connectionTimer); socket.on?.('preference-saved', onSaved); socket.on?.('preference-refused', onRefused); socket.emit('set-preference', { key: 'theme', value: theme }); saveTimer = setTimeout(() => finish(false), 1200); }; if (socket.connected) send(); else { socket.once?.('connect', send); connectionTimer = setTimeout(() => finish(false), 1500); } }); } function fallbackToHaven(file, notify = true, syncPreference = false) { document.querySelectorAll('link[id^="haven-theme-"]').forEach(link => link.remove()); document.documentElement.setAttribute('data-theme', 'haven'); document.documentElement.removeAttribute('data-haven-theme-pending'); localStorage.setItem('haven_theme', 'haven'); setEnabledThemes(getEnabledThemes().filter(name => name !== file)); const record = loadedThemes.get(file); if (record) { record.enabled = false; record.suppressed = false; record.linkEl = null; } document.querySelectorAll('.theme-btn').forEach(button => { button.classList.toggle('active', button.dataset.theme === 'haven'); }); if (!suppressExtensions && typeof applyEffects === 'function') { const fxMode = typeof _getStoredEffectMode === 'function' ? _getStoredEffectMode() : 'auto'; applyEffects(fxMode, 'haven'); } if (!suppressExtensions && typeof showEffectEditorIfDynamic === 'function') { showEffectEditorIfDynamic('haven'); } if (syncPreference) void persistThemePreference('haven'); if (notify) HavenApi.UI.showToast(t('settings.plugins_section.theme_fallback'), 'warning'); renderPluginUI(); } // The server would not save a theme file as this account's theme: it is // not published (see set-preference). Put Haven back rather than show a // theme the account cannot keep. function onThemeRefused(value) { if (typeof value !== 'string' || !value.startsWith('file:')) return; const file = value.slice(5); if (getActiveFileTheme() !== file) return; fallbackToHaven(file, false, true); HavenApi.UI.showToast(t('settings.plugins_section.theme_not_published'), 'warning'); } async function resetCustomizations() { resetLocalCustomizations(); recoveryPending = true; document.querySelectorAll('link[id^="haven-theme-"]').forEach(link => link.remove()); document.documentElement.setAttribute('data-theme', 'haven'); loadedPlugins.forEach(plugin => { plugin.enabled = false; }); loadedThemes.forEach(theme => { theme.enabled = false; theme.suppressed = false; theme.linkEl = null; }); renderPluginUI(); const saved = await persistThemePreference('haven'); if (saved) { recoveryPending = false; clearResetPending(); clearSafeMode(); window.location.assign(urlWithoutSafeMode()); return; } setupSafeModeUI(); HavenApi.UI.showToast(t('settings.plugins_section.reset_sync_pending'), 'warning'); } function setupSafeModeUI() { const notice = document.getElementById('extension-safe-mode-notice'); if (!notice) return; notice.style.display = suppressExtensions ? '' : 'none'; const exitButton = document.getElementById('extension-safe-mode-exit-btn'); if (exitButton) exitButton.style.display = safeMode && !recoveryPending ? '' : 'none'; if (recoveryPending) { const title = document.getElementById('extension-safe-mode-title'); const description = document.getElementById('extension-safe-mode-desc'); if (title) title.textContent = t('settings.plugins_section.recovery_pending_title'); if (description) description.textContent = t('settings.plugins_section.recovery_pending_desc'); } if (!suppressExtensions || notice.dataset.bound === '1') return; notice.dataset.bound = '1'; document.getElementById('extension-reset-btn')?.addEventListener('click', resetCustomizations); document.getElementById('extension-safe-mode-exit-btn')?.addEventListener('click', () => { clearSafeMode(); window.location.assign(urlWithoutSafeMode()); }); } // ═══════════════════════════════════════════════════════ // Settings UI Rendering // ═══════════════════════════════════════════════════════ function themeCompatibilityLabel(meta) { if (meta.compatibility === 'compatible') return `Theme API ${meta.themeApi}`; if (!meta.compatibility || meta.compatibility === 'legacy') return t('settings.plugins_section.legacy_theme'); if (meta.compatibility === 'invalid') return t('settings.plugins_section.invalid_theme_api'); return t('settings.plugins_section.unsupported_theme_api'); } function renderPluginUI() { // The theme picker redraws from the same records, so a theme switched on // or off in Settings shows the same way in the picker. document.dispatchEvent(new CustomEvent('haven:themes-changed')); const container = document.getElementById('plugin-list'); const themeContainer = document.getElementById('theme-list'); if (!container || !themeContainer) return; // Plugins if (loadedPlugins.size === 0) { container.innerHTML = `

${t('settings.plugins_section.no_plugins')}

`; } else { container.innerHTML = ''; for (const [file, p] of loadedPlugins) { const card = document.createElement('div'); card.className = 'plugin-card'; const suppressedLabel = suppressExtensions && p.enabled ? ` • ${escHtml(t('settings.plugins_section.suppressed_safe_mode'))}` : ''; const toggleDisabled = suppressExtensions && !p.enabled; card.innerHTML = `
${escHtml(p.meta.name || file)}
${escHtml(p.meta.description || '')}
${escHtml(p.meta.author || '')}${p.meta.version ? ' • v' + escHtml(p.meta.version) : ''}${suppressedLabel}
`; 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 if (listedThemes.length === 0) { themeContainer.innerHTML = `

${escHtml(t('settings.plugins_section.no_published_themes'))}

`; } 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 = `
${escHtml(themeRecord.meta.name || file)}
${escHtml(themeRecord.meta.description || '')}
${escHtml(themeRecord.meta.author || '')}${themeRecord.meta.version ? ' • v' + escHtml(themeRecord.meta.version) : ''} • ${escHtml(compatibilityLabel)}${publishLabel}${suppressedLabel}
`; 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), }; })();