mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 09:36:49 +00:00
978 lines
39 KiB
JavaScript
978 lines
39 KiB
JavaScript
/**
|
||
* 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 <head> */
|
||
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<boolean> */
|
||
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 = `<p class="plugin-empty">${t('settings.plugins_section.no_plugins')}</p>`;
|
||
} 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 = `
|
||
<div class="plugin-card-info">
|
||
<div class="plugin-card-name">${escHtml(p.meta.name || file)}</div>
|
||
<div class="plugin-card-desc">${escHtml(p.meta.description || '')}</div>
|
||
<div class="plugin-card-meta">${escHtml(p.meta.author || '')}${p.meta.version ? ' • v' + escHtml(p.meta.version) : ''}${suppressedLabel}</div>
|
||
</div>
|
||
<label class="plugin-toggle">
|
||
<input type="checkbox" ${p.enabled ? 'checked' : ''} ${toggleDisabled ? 'disabled' : ''}>
|
||
<span class="plugin-toggle-slider"></span>
|
||
</label>
|
||
`;
|
||
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 = '<p class="plugin-empty">No themes found. Drop <code>.theme.css</code> files into the <code>themes/</code> folder.</p>';
|
||
} else if (listedThemes.length === 0) {
|
||
themeContainer.innerHTML = `<p class="plugin-empty">${escHtml(t('settings.plugins_section.no_published_themes'))}</p>`;
|
||
} 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 = `
|
||
<div class="plugin-card-info">
|
||
<div class="plugin-card-name">${escHtml(themeRecord.meta.name || file)}</div>
|
||
<div class="plugin-card-desc">${escHtml(themeRecord.meta.description || '')}</div>
|
||
<div class="plugin-card-meta">${escHtml(themeRecord.meta.author || '')}${themeRecord.meta.version ? ' • v' + escHtml(themeRecord.meta.version) : ''} • ${escHtml(compatibilityLabel)}${publishLabel}${suppressedLabel}</div>
|
||
</div>
|
||
<label class="plugin-toggle">
|
||
<input type="checkbox" ${themeRecord.enabled ? 'checked' : ''} ${toggleDisabled ? 'disabled' : ''}>
|
||
<span class="plugin-toggle-slider"></span>
|
||
</label>
|
||
`;
|
||
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),
|
||
};
|
||
})();
|