// KefinTweaks Installer // Entry point script for KefinTweaks installation and configuration // Users paste this script into JS Injector instead of injector.js (function() { 'use strict'; console.log('[KefinTweaks Installer] Initializing...'); const MODAL_ID = 'kefinTweaksSourceModal'; let versionsCache = null; function getAuthHeader() { const token = ApiClient.accessToken(); const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web'; const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser'); const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : ''; const version = ApiClient._appVersion || ApiClient._serverVersion || ''; const parts = [ `Client="${encodeURIComponent(client)}"`, `Device="${encodeURIComponent(device)}"`, `DeviceId="${encodeURIComponent(deviceId)}"`, `Version="${encodeURIComponent(version)}"`, `Token="${encodeURIComponent(token)}"` ]; return `MediaBrowser ${parts.join(', ')}`; } // jsDelivr URL patterns const JSDELIVR_BASE = 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks'; const GITHUB_REPO = 'ranaldsgift/KefinTweaks'; const GITHUB_API_RELEASES = `https://api.github.com/repos/${GITHUB_REPO}/releases`; // Check if user is admin async function isAdmin() { // Wait for user to be logged in await waitForLoginForInjectorWrite(); try { if (window.ApiClient && window.ApiClient.getCurrentUser) { const user = await window.ApiClient.getCurrentUser(); return user && user.Policy && user.Policy.IsAdministrator === true; } } catch (error) { console.warn('[KefinTweaks Installer] Could not check admin status:', error); } return false; } // Find JavaScript Injector plugin async function findJavaScriptInjectorPlugin() { try { if (!window.ApiClient || !window.ApiClient._serverAddress || window.ApiClient.accessToken() === null) { return null; } const server = ApiClient._serverAddress; const response = await fetch(`${server}/Plugins`, { headers: { 'Authorization': getAuthHeader() } }); // Non-admins cannot list plugins (HTTP 403) — treat as no access, not a hard failure if (response.status === 403 || response.status === 401) { return null; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const plugins = await response.json(); const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []); const injectorPlugin = pluginsList.find(plugin => plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' ); if (!injectorPlugin) { throw new Error('JavaScript Injector plugin not found'); } return injectorPlugin.Id; } catch (error) { console.error('[KefinTweaks Installer] Error finding JavaScript Injector plugin:', error); throw error; } } // Get current JavaScript Injector configuration async function getJavaScriptInjectorConfig(pluginId) { try { if (!pluginId || !window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { return null; } const server = ApiClient._serverAddress; const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { headers: { 'Authorization': getAuthHeader() } }); if (response.status === 403 || response.status === 401) { return null; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('[KefinTweaks Installer] Error getting JS Injector config:', error); throw error; } } // Get KefinTweaks config from JS Injector async function getKefinTweaksConfig() { try { if (window.KefinTweaksConfig) { return window.KefinTweaksConfig; } const pluginId = await findJavaScriptInjectorPlugin(); if (!pluginId) { return null; } const injectorConfig = await getJavaScriptInjectorConfig(pluginId); if (!injectorConfig) { return null; } const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( script => script.Name === 'KefinTweaks-Config' ); if (kefinTweaksScript && kefinTweaksScript.Script) { const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); if (scriptMatch && scriptMatch[1]) { try { return JSON.parse(scriptMatch[1]); } catch (parseError) { console.error('[KefinTweaks Installer] Error parsing config:', parseError); } } } } catch (error) { console.warn('[KefinTweaks Installer] Error getting config:', error); } return null; } // Load default config from URL (resolves floating refs first, same as injector load) async function loadDefaultConfig(rootUrl) { const normalizedRoot = rootUrl.endsWith('/') ? rootUrl : rootUrl + '/'; const resolvedRoot = await resolveRootVersion(normalizedRoot); if (resolvedRoot !== normalizedRoot) { console.log('[KefinTweaks Installer] Resolved default-config root from', normalizedRoot, 'to', resolvedRoot); } return new Promise((resolve, reject) => { try { const defaultConfigUrl = `${resolvedRoot}kefinTweaks-default-config.js`; if (window.KefinTweaksDefaultConfig) { resolve(window.KefinTweaksDefaultConfig); return; } const script = document.createElement('script'); script.src = defaultConfigUrl; script.async = true; script.onload = () => { if (window.KefinTweaksDefaultConfig) { resolve(window.KefinTweaksDefaultConfig); } else { reject(new Error('Default config file loaded but window.KefinTweaksDefaultConfig is not defined')); } }; script.onerror = () => { reject(new Error(`Failed to load default config file: ${defaultConfigUrl}`)); }; document.head.appendChild(script); } catch (error) { reject(error); } }); } // Save configuration to JavaScript Injector (Config + KefinTweaks-injector preload) async function saveConfigToJavaScriptInjector(config) { try { const pluginId = await findJavaScriptInjectorPlugin(); if (!pluginId) { console.warn('[KefinTweaks Installer] Cannot save config: JavaScript Injector plugin unavailable (non-admin or missing)'); return false; } const injectorConfig = await getJavaScriptInjectorConfig(pluginId); if (!injectorConfig) { console.warn('[KefinTweaks Installer] Cannot save config: could not read JS Injector configuration'); return false; } if (!injectorConfig.CustomJavaScripts) { injectorConfig.CustomJavaScripts = []; } const scriptContent = `// KefinTweaks Configuration // This file is automatically generated by KefinTweaks Installer // Do not edit manually unless you know what you're doing window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; let injectorScriptContent = null; try { const rootRaw = config.kefinTweaksRoot || ''; const resolvedRaw = config.kefinTweaksRootResolved || rootRaw; const root = resolvedRaw.endsWith('/') ? resolvedRaw : resolvedRaw + '/'; if (root && root !== '/') { await loadKefinTweaksLoaderFromRoot(root); const Loader = window.KefinTweaksLoader; if (Loader) { Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); let major = null; try { major = await window.KefinTweaks.getJellyfinMajorVersion(); } catch (e) { console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for injector bake:', e); } const planRoot = Loader.getResolvedKefinRoot ? Loader.getResolvedKefinRoot(config) : root; const plan = Loader.buildLoadPlan(config, major, { root: planRoot, configOnly: config.enabled === false }); injectorScriptContent = Loader.buildInjectorScript(plan); console.log('[KefinTweaks Installer] Built KefinTweaks-injector with', plan.assets.length, 'assets'); } } } catch (e) { console.warn('[KefinTweaks Installer] Failed to build KefinTweaks-injector:', e); } if (window.KefinTweaksLoader) { injectorConfig.CustomJavaScripts = window.KefinTweaksLoader.upsertInjectorEntries( injectorConfig.CustomJavaScripts, { configScriptContent: scriptContent, injectorScriptContent } ); } else { const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( script => script.Name === 'KefinTweaks-Config' ); if (existingScriptIndex !== -1) { injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; injectorConfig.CustomJavaScripts[existingScriptIndex].Enabled = true; } else { injectorConfig.CustomJavaScripts.push({ Name: 'KefinTweaks-Config', Script: scriptContent, Enabled: true, RequiresAuthentication: false }); } } const server = ApiClient._serverAddress; const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { method: 'POST', headers: { 'Authorization': getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return true; } catch (error) { console.error('[KefinTweaks Installer] Error saving config:', error); throw error; } } function loadKefinTweaksLoaderFromRoot(root) { const normalized = root.endsWith('/') ? root : root + '/'; const url = `${normalized}scripts/kefinTweaks-loader.js`; if (window.KefinTweaksLoader) return Promise.resolve(window.KefinTweaksLoader); return new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${url}"]`); if (existing) { const start = Date.now(); const wait = () => { if (window.KefinTweaksLoader) return resolve(window.KefinTweaksLoader); if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader')); setTimeout(wait, 50); }; return wait(); } const script = document.createElement('script'); script.src = url; script.async = false; script.onload = () => resolve(window.KefinTweaksLoader); script.onerror = () => reject(new Error('Failed to load ' + url)); document.head.appendChild(script); }); } function isLoggedInForInjectorWrite() { try { return !!(window.ApiClient && window.ApiClient._loggedIn && typeof window.ApiClient.accessToken === 'function' && window.ApiClient.accessToken() && window.ApiClient._serverAddress); } catch (e) { return false; } } function normalizeKefinRootUrl(root) { if (!root || typeof root !== 'string') return ''; return root.endsWith('/') ? root : root + '/'; } function isFloatingKefinRoot(root) { return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || '')); } /** Human label for a newly pinned resolved root (channel-aware). */ function formatResolvedUpdateLabel(symbolicRoot, resolvedRoot) { const symbolic = String(symbolicRoot || ''); const resolved = String(resolvedRoot || ''); const verMatch = resolved.match(/@v?(\d+\.\d+\.\d+)/i); if (verMatch) { return `v${verMatch[1]}`; } const shaMatch = resolved.match(/@([0-9a-f]{7,40})(?:\/|$)/i); const shortSha = shaMatch ? shaMatch[1].substring(0, 7).toLowerCase() : null; if (/@experimental(\/|$)/i.test(symbolic)) { return shortSha ? `Experimental (#${shortSha})` : 'Experimental'; } if (/@(main|master)(\/|$)/i.test(symbolic)) { return shortSha ? `Dev (#${shortSha})` : 'Development'; } if (shortSha) { return `Dev (#${shortSha})`; } return 'a new version'; } function flushPendingRootUpdateToast() { const pending = window.__kefinPendingRootUpdateToast; if (!pending?.message) return false; if (typeof window.KefinTweaksToaster?.toast !== 'function') return false; window.__kefinPendingRootUpdateToast = null; try { // Sticky when a forced tip-reload countdown is active; otherwise auto-hide. const sticky = !!pending.sticky || !!window.__kefinTipReloadScheduled; window.KefinTweaksToaster.toast(pending.message, pending.duration || '5', !sticky); } catch (e) { console.warn('[KefinTweaks Installer] Failed to show root-update toast:', e); return false; } return true; } function queueRootUpdateToast(message, options = null) { if (!message) return; window.__kefinPendingRootUpdateToast = { message, sticky: !!(options && options.sticky), duration: (options && options.duration) || '5' }; if (flushPendingRootUpdateToast()) return; const start = Date.now(); const poll = () => { if (flushPendingRootUpdateToast()) return; if (Date.now() - start > 30000) return; setTimeout(poll, 250); }; setTimeout(poll, 250); } /** * After tip re-pin is persisted to disk, force reload so Config/bake/scripts stay aligned. * Single-flight per page session. Countdown updates the same toast in place (no reopen). */ function scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved }) { if (window.__kefinTipReloadScheduled) return; window.__kefinTipReloadScheduled = true; const label = formatResolvedUpdateLabel(symbolicRoot, nextResolved); let remaining = 5; const msg = (n) => `KefinTweaks updated to ${label} — refreshing in ${n}…`; let toastHandle = null; const setToastMessage = (el, text) => { if (!el) return; const messageEl = el.querySelector('.kefin-toast-message'); if (messageEl) messageEl.textContent = text; else el.textContent = text; }; const ensureToast = () => { if (toastHandle?.element?.isConnected) return toastHandle; if (typeof window.KefinTweaksToaster?.toast === 'function') { toastHandle = window.KefinTweaksToaster.toast(msg(remaining), '6', false); return toastHandle; } // Toaster not ready yet — queue once; flush will show sticky toast queueRootUpdateToast(msg(remaining), { sticky: true, duration: '6' }); return null; }; const update = (n) => { const handle = ensureToast(); if (handle?.element?.isConnected) { setToastMessage(handle.element, msg(n)); return; } // Pending toast may have been flushed by toaster init — adopt it if present const existing = document.querySelector('.toastContainer .toast'); if (existing) { toastHandle = { element: existing, dismiss: () => {} }; setToastMessage(existing, msg(n)); } }; update(remaining); const tick = setInterval(() => { remaining -= 1; if (remaining <= 0) { clearInterval(tick); try { window.location.reload(); } catch (e) { console.warn('[KefinTweaks Installer] Reload after tip update failed:', e); } return; } update(remaining); }, 1000); } /** * After a floating root's resolved URL was persisted (skip first pin): countdown + reload. */ function maybeQueueResolvedRootUpdateToast({ symbolicRoot, previousResolved, nextResolved, persisted }) { if (!persisted) return; if (!isFloatingKefinRoot(symbolicRoot)) return; const previous = normalizeKefinRootUrl(previousResolved); const next = normalizeKefinRootUrl(nextResolved); if (!previous || !next || previous === next) return; scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved: next }); } /** * Admin + bake already on page: tip resolve/persist/sync without loading live injector.js. * Tip advanced → persist Config+bake then forced reload. Tip unchanged → stamp sync only. */ async function handleAdminAttachedBakeTipSync(config) { const symbolic = config?.kefinTweaksRoot || ''; if (!symbolic) return; await waitForLoginForInjectorWrite(); const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || ''); let tip = normalizeKefinRootUrl(symbolic); if (isFloatingKefinRoot(symbolic)) { tip = normalizeKefinRootUrl(await resolveRootVersion(symbolic)); } if (tip && tip !== current) { console.log('[KefinTweaks Installer] Tip advanced while bake attached:', current || '(none)', '->', tip); config.kefinTweaksRootResolved = tip; if (!window.KefinTweaksConfig) { window.KefinTweaksConfig = config; } else { window.KefinTweaksConfig.kefinTweaksRootResolved = tip; } try { const persisted = await saveConfigToJavaScriptInjector(config); if (persisted) { scheduleForcedTipReloadCountdown({ symbolicRoot: symbolic, nextResolved: tip }); console.log('[KefinTweaks Installer] Tip persist+bake ok; scheduling reload'); } else { console.warn('[KefinTweaks Installer] Tip advanced but persist failed; not reloading'); } } catch (e) { console.error('[KefinTweaks Installer] Tip persist failed; not reloading:', e); } return; } // Tip unchanged — stamp sync only (no live inject, no reload) try { const loadRoot = current || tip; if (!loadRoot) return; await loadKefinTweaksLoaderFromRoot(loadRoot); const Loader = window.KefinTweaksLoader; if (!Loader || typeof Loader.syncKefinTweaksInjector !== 'function') { console.log('[KefinTweaks Installer] Loader sync unavailable; bake attached, tip unchanged'); return; } Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); let major = null; try { major = await window.KefinTweaks.getJellyfinMajorVersion(); } catch (e) { /* ignore */ } const planRoot = Loader.getResolvedKefinRoot ? Loader.getResolvedKefinRoot(config) : loadRoot; const plan = Loader.buildLoadPlan(config, major, { root: planRoot, configOnly: config.enabled === false }); const syncResult = await Loader.syncKefinTweaksInjector(plan); console.log( '[KefinTweaks Installer] Attached bake stamp sync:', syncResult?.synced ? ('synced ' + syncResult.stamp) : (syncResult?.reason || 'done') ); } catch (e) { console.warn('[KefinTweaks Installer] Attached bake stamp sync failed:', e); } } function isKefinInjectorAttachedOnPage() { return !!( window.KefinTweaksInjectorAttached || window.KefinTweaksScriptsPreloaded || window.KefinTweaksInjectorStamp ); } async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) { if (isLoggedInForInjectorWrite()) return true; const start = Date.now(); while (Date.now() - start < maxWaitMs) { await new Promise((r) => setTimeout(r, checkInterval)); if (isLoggedInForInjectorWrite()) return true; } return false; } /** * Create/update KefinTweaks-injector (and Config) in JS Injector when missing. * @returns {Promise<{ ok: boolean, plan?: object, reason?: string }>} */ async function ensureKefinTweaksInjectorEntry(config) { const rootRaw = config?.kefinTweaksRoot || ''; if (!rootRaw) { return { ok: false, reason: 'kefinTweaksRoot not configured' }; } if (!isLoggedInForInjectorWrite()) { return { ok: false, reason: 'not logged in' }; } if (!(await isAdmin())) { return { ok: false, reason: 'not admin' }; } try { const expected = normalizeKefinRootUrl(await resolveRootVersion(rootRaw)); const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || ''); const resolvedRootUpdated = !!(current && expected && current !== expected); if (!current || current !== expected) { config.kefinTweaksRootResolved = expected; console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected); } const loadRoot = normalizeKefinRootUrl(config.kefinTweaksRootResolved || rootRaw); await loadKefinTweaksLoaderFromRoot(loadRoot); const Loader = window.KefinTweaksLoader; if (!Loader) { return { ok: false, reason: 'KefinTweaksLoader unavailable' }; } Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); let major = null; try { major = await window.KefinTweaks.getJellyfinMajorVersion(); } catch (e) { console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for auto-create:', e); } const resolvedRoot = Loader.getResolvedKefinRoot(config); const plan = Loader.buildLoadPlan(config, major, { root: resolvedRoot, configOnly: config.enabled === false }); const injectorScriptContent = Loader.buildInjectorScript(plan); const configScriptContent = `// KefinTweaks Configuration // This file is automatically generated by KefinTweaks Installer // Do not edit manually unless you know what you're doing window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const pluginId = await findJavaScriptInjectorPlugin(); if (!pluginId) { return { ok: false, reason: 'JavaScript Injector plugin not found' }; } const injectorConfig = await getJavaScriptInjectorConfig(pluginId); if (!injectorConfig) { return { ok: false, reason: 'Could not read JS Injector configuration' }; } if (!injectorConfig.CustomJavaScripts) { injectorConfig.CustomJavaScripts = []; } injectorConfig.CustomJavaScripts = Loader.upsertInjectorEntries( injectorConfig.CustomJavaScripts, { configScriptContent, injectorScriptContent } ); const server = ApiClient._serverAddress; const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { method: 'POST', headers: { 'Authorization': getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) }); if (!response.ok) { return { ok: false, reason: `POST failed HTTP ${response.status}: ${response.statusText}` }; } if (resolvedRootUpdated) { maybeQueueResolvedRootUpdateToast({ symbolicRoot: rootRaw, previousResolved: current, nextResolved: expected, persisted: true }); } console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets'); return { ok: true, plan }; } catch (err) { console.warn('[KefinTweaks Installer] ensureKefinTweaksInjectorEntry failed:', err); return { ok: false, reason: err?.message || String(err) }; } } function hasEnabledKefinInjectorEntry(customJavaScripts) { if (window.KefinTweaksLoader?.hasEnabledInjectorEntry) { return window.KefinTweaksLoader.hasEnabledInjectorEntry(customJavaScripts); } return (customJavaScripts || []).some( (s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false ); } const LATEST_RELEASE_NAME = 'Latest'; const DEVELOPMENT_NAME = 'Development'; const EXPERIMENTAL_NAME = 'Experimental'; const SPECIFIC_BUILD_NAME = 'Specific Build'; const SELF_HOSTED_NAME = 'Self Hosted'; function isFullCommitSha(value) { return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value.trim()); } // Parse source URL to determine source type and version function parseKefinTweaksSource(url) { if (!url || url === '') { return { sourceType: 'github', version: LATEST_RELEASE_NAME }; } const match = url.match(/cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@([^\/]+)\//); if (!match) { return { sourceType: 'custom', version: null }; } const version = match[1]; if (version === 'latest') { return { sourceType: 'github', version: LATEST_RELEASE_NAME }; } else if (version === 'main' || version === 'master') { return { sourceType: 'github', version: DEVELOPMENT_NAME }; } else if (version === 'experimental') { return { sourceType: 'github', version: EXPERIMENTAL_NAME }; } else if (isFullCommitSha(version)) { return { sourceType: 'github', version: SPECIFIC_BUILD_NAME, commit: version.toLowerCase() }; } else { return { sourceType: 'github', version: version.replace(/^v/, '') }; } } // Fetch available versions from GitHub async function fetchVersions() { if (versionsCache) { return versionsCache; } try { const response = await fetch(GITHUB_API_RELEASES); if (!response.ok) { throw new Error('Failed to fetch releases'); } const releases = await response.json(); const versions = releases .map(release => release.tag_name.replace(/^v/, '')) .filter(tag => tag.match(/^\d+\.\d+\.\d+/)); // Only semantic versions versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME, ...versions]; return versionsCache; } catch (error) { console.warn('[KefinTweaks Installer] Error fetching versions:', error); return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME]; } } // Build kefinTweaksRoot URL from selections function buildKefinTweaksRootUrl(sourceType, source, commitHash) { if (sourceType === 'custom') { // Ensure URL ends with / return source.endsWith('/') ? source : source + '/'; } // GitHub source (branch names like main/experimental are resolved by jsDelivr to latest commit at load time) let versionTag = 'latest'; if (source === DEVELOPMENT_NAME) { versionTag = 'main'; } else if (source === EXPERIMENTAL_NAME) { versionTag = 'experimental'; } else if (source === SPECIFIC_BUILD_NAME) { const sha = String(commitHash || '').trim().toLowerCase(); if (!isFullCommitSha(sha)) { throw new Error('Specific Build requires a full 40-character commit hash'); } versionTag = sha; } else if (source !== LATEST_RELEASE_NAME) { versionTag = source.startsWith('v') ? source : 'v' + source; } return `${JSDELIVR_BASE}@${versionTag}/`; } function formatSpecificBuildDisplay(sourceInfo) { const sha = sourceInfo?.commit || ''; return sha ? `${SPECIFIC_BUILD_NAME} (#${sha.slice(0, 7)})` : SPECIFIC_BUILD_NAME; } // Simple modal creation (doesn't depend on modal.js) function createSimpleModal(id, title, content, footer) { // Remove existing modal const existing = document.getElementById(id); if (existing) { existing.remove(); } const backdrop = document.createElement('div'); backdrop.className = 'dialogBackdrop dialogBackdropOpened'; backdrop.id = id + '-backdrop'; const dialogContainer = document.createElement('div'); dialogContainer.className = 'dialogContainer'; dialogContainer.id = id; const dialog = document.createElement('div'); dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened'; dialog.style.display = 'flex'; dialog.style.flexDirection = 'column'; if (window.innerWidth < 900) { dialog.style.maxHeight = '90vh'; dialog.style.maxWidth = '600px'; dialog.style.width = '90vw'; } if (title) { const header = document.createElement('div'); header.className = 'formDialogHeader'; header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.style.padding = '1.25em 1.5em'; header.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; const titleEl = document.createElement('h2'); titleEl.style.margin = '0'; titleEl.textContent = title; header.appendChild(titleEl); const closeBtn = document.createElement('button'); closeBtn.setAttribute('is', 'paper-icon-button-light'); closeBtn.className = 'btnCancel btnClose autoSize paper-icon-button-light'; closeBtn.onclick = () => closeSimpleModal(id); const closeIcon = document.createElement('span'); closeIcon.className = 'material-icons close'; closeBtn.appendChild(closeIcon); header.appendChild(closeBtn); dialog.appendChild(header); } const contentDiv = document.createElement('div'); contentDiv.style.padding = '1.25em 1.5em 8em'; contentDiv.style.overflowY = 'auto'; contentDiv.style.flex = '1'; if (typeof content === 'string') { contentDiv.innerHTML = content; } else { contentDiv.appendChild(content); } dialog.appendChild(contentDiv); if (footer) { const footerDiv = document.createElement('div'); footerDiv.className = 'formDialogFooter'; footerDiv.style.padding = '1.25em 1.5em'; footerDiv.style.borderTop = '1px solid rgba(255,255,255,0.1)'; if (typeof footer === 'string') { footerDiv.innerHTML = footer; } else { footerDiv.appendChild(footer); } dialog.appendChild(footerDiv); } dialogContainer.appendChild(dialog); document.body.appendChild(backdrop); document.body.appendChild(dialogContainer); backdrop.onclick = (e) => { if (e.target === backdrop) { closeSimpleModal(id); } }; // Close modal when clicking outside (on dialogContainer) dialogContainer.onclick = (e) => { if (e.target === dialogContainer) { closeSimpleModal(id); } }; return { id, close: () => closeSimpleModal(id) }; } function closeSimpleModal(id) { const backdrop = document.getElementById(id + '-backdrop'); const dialog = document.getElementById(id); if (backdrop) backdrop.remove(); if (dialog) dialog.remove(); } /** * Show a confirmation modal * @param {string} message - Confirmation message * @param {Function} onConfirm - Callback when user confirms * @param {Function} onCancel - Optional callback when user cancels */ function showConfirmationModal(message, onConfirm, onCancel = null) { const CONFIRM_MODAL_ID = 'kefinTweaksConfirmModal'; const content = document.createElement('div'); content.style.display = 'flex'; content.style.flexDirection = 'column'; content.style.gap = '1em'; content.style.lineHeight = '1.6'; content.innerHTML = `

${message}

`; const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.justifyContent = 'flex-end'; footer.style.gap = '1em'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btnCancel raised emby-button button-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { closeSimpleModal(CONFIRM_MODAL_ID); if (onCancel) onCancel(); }; const confirmBtn = document.createElement('button'); confirmBtn.className = 'btnSubmit raised emby-button button-submit'; confirmBtn.textContent = 'Confirm'; confirmBtn.onclick = () => { closeSimpleModal(CONFIRM_MODAL_ID); if (onConfirm) onConfirm(); }; footer.appendChild(cancelBtn); footer.appendChild(confirmBtn); createSimpleModal(CONFIRM_MODAL_ID, 'Confirm', content, footer); } /** * Show an alert/info modal * @param {string} title - Modal title * @param {string} message - Message content (can include HTML) */ function showAlertModal(title, message) { const ALERT_MODAL_ID = 'kefinTweaksAlertModal'; const content = document.createElement('div'); content.style.display = 'flex'; content.style.flexDirection = 'column'; content.style.gap = '1em'; content.style.lineHeight = '1.6'; content.innerHTML = message; const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.justifyContent = 'flex-end'; footer.style.gap = '1em'; const okBtn = document.createElement('button'); okBtn.className = 'btnSubmit raised emby-button button-submit'; okBtn.textContent = 'OK'; okBtn.onclick = () => { closeSimpleModal(ALERT_MODAL_ID); }; footer.appendChild(okBtn); createSimpleModal(ALERT_MODAL_ID, title, content, footer); } /** * Show success modal after saving configuration * @param {boolean} isInitialInstall - Whether this is the initial installation * @param {string} version - Version number or source type */ async function showSuccessModal(isInitialInstall, version) { const SUCCESS_MODAL_ID = 'kefinTweaksSuccessModal'; const content = document.createElement('div'); content.style.display = 'flex'; content.style.flexDirection = 'column'; content.style.lineHeight = '1.6'; if (isInitialInstall) { // Initial installation message with support information content.innerHTML = `

Thanks so much for installing KefinTweaks!

KefinTweaks is not maintained by the Jellyfin team, and as a result you are encouraged to seek support from me directly.

Sadly, there is currently no place suitable for discussion of plugins built by community members or fan-made projects in the official Jellyfin Discord, so please visit the Jellyfin Community Discord to find me (username: HighImKevin) and other users who would be happy to help you out.

Please also feel free to report bugs and request features from the Issues page. The strength of this plugin relies on awesome community members like you, so thanks for using KefinTweaks!

`; } else { // Update message with refresh instruction content.innerHTML = `

KefinTweaks configuration has been successfully updated. Please refresh your page for the changes to take effect!

`; } // Add discord icon link to the bottom of the content use a background img on an element const kefinRoot = window.KefinTweaksConfig?.kefinTweaksRoot; if (kefinRoot) { // Wrapped in a link to the discord server const discordLinkWrapper = document.createElement('div'); discordLinkWrapper.style.display = 'flex'; discordLinkWrapper.style.justifyContent = 'center'; const discordLink = document.createElement('a'); discordLink.href = 'https://discord.gg/v7P9CAvCKZ'; discordLink.target = '_blank'; discordLink.style.display = 'inline-block'; discordLink.style.width = '50px'; const discordIcon = document.createElement('div'); discordIcon.style.backgroundImage = `url(${kefinRoot}/pages/images/discord.png)`; discordIcon.style.backgroundSize = 'contain'; discordIcon.style.backgroundPosition = 'center'; discordIcon.style.backgroundRepeat = 'no-repeat'; discordIcon.style.height = '35px'; discordLink.appendChild(discordIcon); discordLinkWrapper.appendChild(discordLink); content.appendChild(discordLinkWrapper); } const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.justifyContent = 'flex-end'; footer.style.gap = '1em'; const okBtn = document.createElement('button'); okBtn.className = 'btnSubmit raised emby-button button-submit'; okBtn.textContent = 'OK'; okBtn.onclick = () => { closeSimpleModal(SUCCESS_MODAL_ID); if (!isInitialInstall) { // Optionally auto-refresh on update (or let user do it manually) // window.location.reload(); } }; footer.appendChild(okBtn); // Close original KefinTweaksConfiguration Modal if it is open const kefinTweaksConfigurationModals = document.querySelectorAll('[data-modal-id="kefinTweaksConfigModal"]'); if (kefinTweaksConfigurationModals.length > 0) { kefinTweaksConfigurationModals.forEach(modal => { modal.remove(); }); } createSimpleModal(SUCCESS_MODAL_ID, `${isInitialInstall ? 'Plugin Installed' : 'Plugin Updated'}`, content, footer); } // Open source configuration modal async function openKefinTweaksSourceModal() { const userIsAdmin = await isAdmin(); if (!userIsAdmin) { showAlertModal('Access Denied', '

You must be an administrator to configure KefinTweaks.

'); return; } const currentConfig = await getKefinTweaksConfig(); const currentRoot = currentConfig?.kefinTweaksRoot || ''; const sourceInfo = parseKefinTweaksSource(currentRoot); const versions = await fetchVersions(); const content = document.createElement('div'); content.style.display = 'flex'; content.style.flexDirection = 'column'; content.style.gap = '0.5em'; // Enabled checkbox const isEnabled = currentConfig?.enabled !== false; const enabledContainer = document.createElement('div'); enabledContainer.style.display = 'flex'; enabledContainer.style.alignItems = 'center'; enabledContainer.style.gap = '0.75em'; enabledContainer.style.marginBottom = '0.75em'; const enabledCheckbox = document.createElement('input'); enabledCheckbox.type = 'checkbox'; enabledCheckbox.id = 'kefinTweaksEnabledModal'; enabledCheckbox.className = 'checkbox'; enabledCheckbox.checked = isEnabled; const enabledLabel = document.createElement('label'); enabledLabel.setAttribute('for', 'kefinTweaksEnabledModal'); enabledLabel.style.cursor = 'pointer'; enabledLabel.style.margin = '0'; enabledLabel.style.display = 'flex'; enabledLabel.style.alignItems = 'center'; const enabledText = document.createElement('span'); enabledText.textContent = isEnabled ? 'Enabled' : 'Disabled'; enabledText.id = 'kefinTweaksEnabledModalLabel'; enabledLabel.appendChild(enabledCheckbox); enabledLabel.appendChild(enabledText); enabledContainer.appendChild(enabledLabel); // Update label text when checkbox changes enabledCheckbox.addEventListener('change', () => { enabledText.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); content.appendChild(enabledContainer); // Source Type dropdown const sourceTypeLabel = document.createElement('label'); sourceTypeLabel.textContent = 'Install From:'; sourceTypeLabel.style.display = 'block'; const sourceTypeSelect = document.createElement('select'); sourceTypeSelect.className = 'fld emby-select emby-select-withcolor'; sourceTypeSelect.id = 'kefinTweaksSourceType'; sourceTypeSelect.innerHTML = ` `; // Source dropdown (for GitHub) const sourceLabel = document.createElement('label'); sourceLabel.textContent = 'Version:'; sourceLabel.style.display = 'block'; const sourceSelect = document.createElement('select'); sourceSelect.className = 'fld emby-select emby-select-withcolor'; sourceSelect.id = 'kefinTweaksSource'; sourceSelect.innerHTML = versions.map(v => `` ).join(''); // Custom URL input const customUrlInput = document.createElement('input'); customUrlInput.type = 'text'; customUrlInput.className = 'fld emby-input'; customUrlInput.id = 'kefinTweaksCustomUrl'; customUrlInput.placeholder = 'https://example.com/KefinTweaks/'; customUrlInput.value = sourceInfo.sourceType === 'custom' ? currentRoot : ''; customUrlInput.style.display = sourceInfo.sourceType === 'custom' ? 'block' : 'none'; // Specific Build commit hash input const commitHashLabel = document.createElement('label'); commitHashLabel.textContent = 'Commit Hash:'; commitHashLabel.style.display = 'block'; commitHashLabel.style.marginBottom = '0.5em'; commitHashLabel.style.marginTop = '1em'; const commitHashInput = document.createElement('input'); commitHashInput.type = 'text'; commitHashInput.className = 'fld emby-input'; commitHashInput.id = 'kefinTweaksCommitHash'; commitHashInput.placeholder = '40-character git commit SHA'; commitHashInput.spellcheck = false; commitHashInput.autocomplete = 'off'; commitHashInput.value = sourceInfo.commit || ''; // Resolved hash readout for Development / Experimental const resolvedHashEl = document.createElement('div'); resolvedHashEl.id = 'kefinTweaksResolvedHash'; resolvedHashEl.className = 'listItemBodyText secondary'; resolvedHashEl.style.display = 'none'; resolvedHashEl.style.marginTop = '0.35em'; resolvedHashEl.style.fontFamily = 'monospace'; resolvedHashEl.style.fontSize = '0.9em'; resolvedHashEl.style.wordBreak = 'break-all'; function extractShaFromRoot(root) { if (!root || typeof root !== 'string') return null; const m = root.match(/@([0-9a-f]{7,40})(?:\/|$)/i); return m ? m[1].toLowerCase() : null; } async function updateResolvedHashVisibility() { const show = sourceTypeSelect.value === 'github' && (sourceSelect.value === DEVELOPMENT_NAME || sourceSelect.value === EXPERIMENTAL_NAME); if (!show) { resolvedHashEl.style.display = 'none'; resolvedHashEl.textContent = ''; return; } resolvedHashEl.style.display = 'block'; resolvedHashEl.textContent = 'Version: …'; let sha = extractShaFromRoot(currentConfig?.kefinTweaksRootResolved); const symbolicMatchesSelection = (sourceSelect.value === DEVELOPMENT_NAME && /@(main|master)(\/|$)/.test(currentRoot || '')) || (sourceSelect.value === EXPERIMENTAL_NAME && /@experimental(\/|$)/.test(currentRoot || '')); // Prefer stored resolved hash when it matches the selected branch; otherwise resolve live if (!sha || !symbolicMatchesSelection || sourceSelect.value !== sourceInfo.version) { try { const branchRoot = sourceSelect.value === EXPERIMENTAL_NAME ? `${JSDELIVR_BASE}@experimental/` : `${JSDELIVR_BASE}@main/`; const resolved = await resolveRootVersion(branchRoot); sha = extractShaFromRoot(resolved) || sha; } catch (e) { console.warn('[KefinTweaks Installer] Could not resolve branch hash:', e); } } if (sha) { const short = sha.length > 12 ? sha.slice(0, 12) : sha; resolvedHashEl.textContent = `Version: ${short}`; } else { resolvedHashEl.textContent = 'Version: unavailable'; } } const updateCommitHashVisibility = () => { const showCommit = sourceTypeSelect.value === 'github' && sourceSelect.value === SPECIFIC_BUILD_NAME; commitHashLabel.style.display = showCommit ? 'block' : 'none'; commitHashInput.style.display = showCommit ? 'block' : 'none'; updateResolvedHashVisibility(); }; // Set initial visibility based on source type if (sourceInfo.sourceType === 'custom') { sourceSelect.style.display = 'none'; sourceLabel.style.display = 'none'; customUrlInput.style.display = 'block'; } else { sourceSelect.style.display = 'block'; sourceLabel.style.display = 'block'; customUrlInput.style.display = 'none'; } updateCommitHashVisibility(); // Toggle visibility based on source type sourceTypeSelect.onchange = () => { if (sourceTypeSelect.value === 'custom') { sourceSelect.style.display = 'none'; sourceLabel.style.display = 'none'; customUrlInput.style.display = 'block'; } else { sourceSelect.style.display = 'block'; sourceLabel.style.display = 'block'; customUrlInput.style.display = 'none'; } updateCommitHashVisibility(); }; sourceSelect.onchange = () => { updateCommitHashVisibility(); }; content.appendChild(sourceTypeLabel); content.appendChild(sourceTypeSelect); content.appendChild(sourceLabel); content.appendChild(sourceSelect); content.appendChild(resolvedHashEl); content.appendChild(commitHashLabel); content.appendChild(commitHashInput); content.appendChild(customUrlInput); // Footer with buttons const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.justifyContent = 'flex-end'; footer.style.gap = '1em'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btnCancel raised emby-button button-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => closeSimpleModal(MODAL_ID); const saveBtn = document.createElement('button'); saveBtn.className = 'btnSubmit raised emby-button button-submit'; saveBtn.textContent = (!currentRoot || currentRoot.trim() === '') ? 'Install' : 'Save'; saveBtn.onclick = async () => { try { const sourceType = sourceTypeSelect.value; const source = sourceType === 'custom' ? customUrlInput.value : sourceSelect.value; if (sourceType === 'custom' && (!source || source.trim() === '')) { showAlertModal('Invalid URL', '

Please enter a valid URL for your self-hosted location.

'); return; } let commitHash = ''; if (sourceType === 'github' && source === SPECIFIC_BUILD_NAME) { commitHash = (commitHashInput.value || '').trim().toLowerCase(); if (!isFullCommitSha(commitHash)) { showAlertModal( 'Invalid Commit Hash', '

Please enter a full 40-character git commit SHA for Specific Build.

' ); return; } } const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source, commitHash); const kefinTweaksRootResolvedRaw = await resolveRootVersion(kefinTweaksRoot); const kefinTweaksRootResolved = kefinTweaksRootResolvedRaw.endsWith('/') ? kefinTweaksRootResolvedRaw : kefinTweaksRootResolvedRaw + '/'; // Load default config from the selected root let defaultConfig; try { defaultConfig = await loadDefaultConfig(kefinTweaksRoot); } catch (error) { console.error('[KefinTweaks Installer] Error loading default config:', error); // Create minimal config if loading fails defaultConfig = { kefinTweaksRoot: kefinTweaksRoot, kefinTweaksRootResolved: kefinTweaksRootResolved, enabled: true, scripts: {}, exclusiveElsewhere: {}, search: {}, skins: [], defaultSkin: null, themes: [], customMenuLinks: [], optionalIncludes: [] }; } // Get enabled state from checkbox const enabledState = enabledCheckbox.checked; // Merge with existing config to preserve user settings const mergedConfig = { ...defaultConfig, kefinTweaksRoot: kefinTweaksRoot, ...(currentConfig || {}), kefinTweaksRoot: kefinTweaksRoot, // Ensure root is updated kefinTweaksRootResolved: kefinTweaksRootResolved, enabled: enabledState // Update enabled state }; // Remove scriptRoot if it exists (legacy field) if (mergedConfig.scriptRoot) { delete mergedConfig.scriptRoot; } await saveConfigToJavaScriptInjector(mergedConfig); // Update window.KefinTweaksConfig immediately so injector can use it window.KefinTweaksConfig = mergedConfig; window.KefinTweaksConfigEnabled = enabledState; closeSimpleModal(MODAL_ID); // Update the plugin card with new version/status await updateKefinTweaksPluginCard(); // Determine if this is initial installation const isInitialInstall = !currentConfig || !currentConfig.kefinTweaksRoot || currentConfig.kefinTweaksRoot === ''; // Get version for display const sourceInfo = parseKefinTweaksSource(kefinTweaksRoot); let versionDisplay = ''; if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { // Fetch the actual latest version number try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); if (response.ok) { const data = await response.json(); const versionNumber = data.tag_name.replace(/^v/, ''); versionDisplay = `${versionNumber} (${LATEST_RELEASE_NAME})`; } else { versionDisplay = LATEST_RELEASE_NAME; } } catch (error) { console.warn('[KefinTweaks Installer] Error fetching latest version:', error); versionDisplay = LATEST_RELEASE_NAME; } } else { versionDisplay = sourceInfo.version; } // Always load live injector.js for plan apply + stamp sync (baked preload may be stale) let hasPreload = false; try { const pluginId = await findJavaScriptInjectorPlugin(); const injCfg = await getJavaScriptInjectorConfig(pluginId); hasPreload = (injCfg.CustomJavaScripts || []).some( (s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false ); } catch (e) { console.warn('[KefinTweaks Installer] Could not verify KefinTweaks-injector after save:', e); } if (hasPreload) { console.log('[KefinTweaks Installer] KefinTweaks-injector saved; still loading injector.js for live plan + sync'); } await loadInjectorFromRoot(kefinTweaksRoot); // Show success modal await showSuccessModal(isInitialInstall, versionDisplay); } catch (error) { console.error('[KefinTweaks Installer] Error saving configuration:', error); showAlertModal('Configuration Error', `

Error saving configuration: ${error.message}

`); } }; const uninstallBtn = document.createElement('button'); uninstallBtn.className = 'btnCancel raised emby-button button-cancel'; uninstallBtn.textContent = 'Uninstall'; uninstallBtn.style.marginRight = 'auto'; // Hide uninstall button if KefinTweaks isn't installed yet (no root URL) if (!currentRoot || currentRoot.trim() === '') { uninstallBtn.style.display = 'none'; } uninstallBtn.onclick = () => { closeSimpleModal(MODAL_ID); showConfirmationModal( 'Are you sure you want to uninstall KefinTweaks?

This will disable the installer and backup your configuration.', async () => { try { await uninstallKefinTweaks(); // Remove the plugin card removeKefinTweaksPluginCard(); showAlertModal( 'KefinTweaks Uninstalled', '

KefinTweaks has been uninstalled. The installer has been disabled and your configuration has been backed up.

Please refresh your page.

You can re-enable KefinTweaks at any time from the JS Injector Plugin.' ); } catch (error) { console.error('[KefinTweaks Installer] Error uninstalling:', error); showAlertModal( 'Uninstall Error', `

Error uninstalling KefinTweaks: ${error.message}

` ); } }, () => { // User cancelled, reopen the source modal openKefinTweaksSourceModal(); } ); }; footer.appendChild(saveBtn); footer.appendChild(cancelBtn); footer.appendChild(uninstallBtn); createSimpleModal(MODAL_ID, 'KefinTweaks Plugin Settings', content, footer); } /** * Uninstall KefinTweaks * 1. Renames KefinTweaks-Config to KefinTweaks-Config-Backup-Timestamp * 2. Disables the installer script (script containing kefinTweaks-plugin.js) */ async function uninstallKefinTweaks() { try { const pluginId = await findJavaScriptInjectorPlugin(); const injectorConfig = await getJavaScriptInjectorConfig(pluginId); if (!injectorConfig.CustomJavaScripts) { throw new Error('No custom scripts found in JS Injector configuration'); } // Step 1: Rename KefinTweaks-Config to backup const configScriptIndex = injectorConfig.CustomJavaScripts.findIndex( script => script.Name === 'KefinTweaks-Config' ); if (configScriptIndex !== -1) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); injectorConfig.CustomJavaScripts[configScriptIndex].Name = `KefinTweaks-Config-Backup-${timestamp}`; injectorConfig.CustomJavaScripts[configScriptIndex].Enabled = false; console.log('[KefinTweaks Installer] Renamed KefinTweaks-Config to backup and disabled it'); } // Also disable/rename KefinTweaks-injector preload entry const injectorScriptIndex = injectorConfig.CustomJavaScripts.findIndex( script => script.Name === 'KefinTweaks-injector' ); if (injectorScriptIndex !== -1) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); injectorConfig.CustomJavaScripts[injectorScriptIndex].Name = `KefinTweaks-injector-Backup-${timestamp}`; injectorConfig.CustomJavaScripts[injectorScriptIndex].Enabled = false; console.log('[KefinTweaks Installer] Renamed KefinTweaks-injector to backup and disabled it'); } // Step 2: Find and disable installer script // Look for script containing kefinTweaks-plugin.js (flexible pattern to match script.src*kefinTweaks-plugin.js) const installerScriptIndex = injectorConfig.CustomJavaScripts.findIndex( script => script.Script && /script\.src.*kefinTweaks-plugin\.js/i.test(script.Script) ); if (installerScriptIndex !== -1) { injectorConfig.CustomJavaScripts[installerScriptIndex].Enabled = false; console.log('[KefinTweaks Installer] Disabled installer script'); } else { console.warn('[KefinTweaks Installer] Installer script not found in CustomJavaScripts'); } // Save the updated configuration const server = ApiClient._serverAddress; const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { method: 'POST', headers: { 'Authorization': getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } console.log('[KefinTweaks Installer] Uninstall completed successfully'); return true; } catch (error) { console.error('[KefinTweaks Installer] Error uninstalling:', error); throw error; } } /** * Update the plugin card with new version and status information */ async function updateKefinTweaksPluginCard() { const card = document.querySelector('[data-id="kefinTweaksPlugin"]'); if (!card) { // Card doesn't exist yet, try to add it const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); if (pluginsPage) { let installedPlugins = pluginsPage.querySelector('.installedPlugins'); if (!installedPlugins) { installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div'); } if (installedPlugins) { addKefinTweaksPluginCard(installedPlugins); } } return; } try { const config = await getKefinTweaksConfig(); const root = config?.kefinTweaksRoot || ''; const sourceInfo = parseKefinTweaksSource(root); // Determine version display text let versionDisplay = 'Installation Pending'; if (root) { if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { // Fetch the actual latest version number try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); if (response.ok) { const data = await response.json(); const versionNumber = data.tag_name.replace(/^v/, ''); versionDisplay = `${versionNumber} (${LATEST_RELEASE_NAME})`; } else { versionDisplay = LATEST_RELEASE_NAME; } } catch (error) { console.warn('[KefinTweaks Installer] Error fetching latest version:', error); versionDisplay = LATEST_RELEASE_NAME; } } else { // Specific version versionDisplay = sourceInfo.version; } } // Update data attributes card.setAttribute('data-version', versionDisplay); card.setAttribute('data-status', root ? 'Active' : 'Pending'); // Check if this is a Material-UI card const isMuiCard = card.classList.contains('MuiGrid-root'); if (isMuiCard) { // Handle Material-UI card structure const versionElement = card.querySelector('.MuiTypography-root.MuiTypography-body2'); if (versionElement) { versionElement.textContent = versionDisplay; } } else { // Handle standard card structure const cardTextElements = card.querySelectorAll('.cardText'); if (cardTextElements.length > 0) { // Update first cardText (plugin name with version) const nameElement = cardTextElements[0]; const versionSpan = nameElement.querySelector('.cardText-secondary'); if (versionSpan) { versionSpan.textContent = versionDisplay; } else { // If no secondary span exists, create one nameElement.innerHTML = `KefinTweaks${versionDisplay}`; } } // Determine status text const statusText = root ? 'Status Active' : 'Click to Install'; // Update status text (usually last cardText) if (cardTextElements.length > 0) { cardTextElements[cardTextElements.length - 1].textContent = statusText; } } } catch (error) { console.error('[KefinTweaks Installer] Error updating plugin card:', error); } } /** * Remove the plugin card from the DOM */ function removeKefinTweaksPluginCard() { const card = document.querySelector('[data-id="kefinTweaksPlugin"]'); if (card) { card.remove(); console.log('[KefinTweaks Installer] Plugin card removed'); } } // Make function globally accessible window.openKefinTweaksSourceModal = openKefinTweaksSourceModal; // Prevent concurrent card inserts (async config fetch races past DOM checks) let pluginCardAddInProgress = false; let pluginCardObserver = null; let tryRenderToken = 0; function ensureSingleKefinTweaksPluginCard() { const cards = document.querySelectorAll('[data-id="kefinTweaksPlugin"]'); for (let i = 1; i < cards.length; i++) { cards[i].remove(); } return cards[0] || null; } // Add plugin card to plugins page function addKefinTweaksPluginCard(installedPlugins) { if (!installedPlugins) { return; } if (ensureSingleKefinTweaksPluginCard()) { return; } if (pluginCardAddInProgress) { return; } pluginCardAddInProgress = true; const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); if (!pluginsPage) { pluginCardAddInProgress = false; return; } // Find or create frontEndPlugins container (only one) let frontEndPlugins = pluginsPage.querySelector('.frontEndPlugins'); if (!frontEndPlugins) { // Create the heading const heading = document.createElement('div'); heading.className = 'sectionTitleContainer flex align-items-center'; heading.innerHTML = '

Front End Plugins

'; heading.style.marginTop = '2em'; // Create the frontEndPlugins container frontEndPlugins = document.createElement('div'); frontEndPlugins.className = 'frontEndPlugins itemsContainer vertical-wrap'; // Insert heading and container after installedPlugins if (installedPlugins.nextSibling) { installedPlugins.parentNode.insertBefore(heading, installedPlugins.nextSibling); installedPlugins.parentNode.insertBefore(frontEndPlugins, heading.nextSibling); } else { installedPlugins.parentNode.appendChild(heading); installedPlugins.parentNode.appendChild(frontEndPlugins); } } // Find an existing plugin card to clone // Try multiple selectors to find a plugin card const existingCard = installedPlugins.querySelector('.card[data-id]') || installedPlugins.querySelector(':first-child') || installedPlugins.firstElementChild; if (!existingCard) { if (pluginCardObserver) { // Already waiting for native plugin cards; keep in-progress lock return; } console.log('[KefinTweaks Installer] No existing plugin card found, setting up MutationObserver'); pluginCardObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.addedNodes.length > 0) { const cardToClone = installedPlugins.querySelector('.card[data-id]') || installedPlugins.querySelector(':first-child') || installedPlugins.firstElementChild; if (cardToClone) { console.log('[KefinTweaks Installer] Plugin card detected, disconnecting observer'); pluginCardObserver.disconnect(); pluginCardObserver = null; pluginCardAddInProgress = false; addKefinTweaksPluginCard(installedPlugins); return; } } } }); pluginCardObserver.observe(installedPlugins, { childList: true, subtree: false }); setTimeout(() => { if (pluginCardObserver) { pluginCardObserver.disconnect(); pluginCardObserver = null; pluginCardAddInProgress = false; console.warn('[KefinTweaks Installer] MutationObserver timeout: No plugin cards appeared after 10 seconds'); } }, 10000); return; } // Get current config to determine version/status getKefinTweaksConfig().then(async config => { const root = config?.kefinTweaksRoot || ''; const sourceInfo = parseKefinTweaksSource(root); // Determine version display text let versionDisplay = 'Installation Pending'; if (root) { if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { // Fetch the actual latest version number try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); if (response.ok) { const data = await response.json(); const versionNumber = data.tag_name.replace(/^v/, ''); versionDisplay = `${versionNumber} (${LATEST_RELEASE_NAME})`; } else { versionDisplay = LATEST_RELEASE_NAME; } } catch (error) { console.warn('[KefinTweaks Installer] Error fetching latest version:', error); versionDisplay = LATEST_RELEASE_NAME; } } else { // Specific version versionDisplay = sourceInfo.version; } } // Clone the existing card const card = existingCard.cloneNode(true); // Update data attributes card.setAttribute('data-id', 'kefinTweaksPlugin'); card.setAttribute('data-version', versionDisplay); card.setAttribute('data-name', 'KefinTweaks'); card.setAttribute('data-removable', 'true'); card.setAttribute('data-status', root ? 'Active' : 'Pending'); // Check if this is a Material-UI card (MuiGrid-root) const isMuiCard = card.classList.contains('MuiGrid-root'); if (isMuiCard) { // Handle Material-UI card structure const nameElement = card.querySelector('.MuiTypography-root.MuiTypography-body1'); const versionElement = card.querySelector('.MuiTypography-root.MuiTypography-body2'); if (nameElement) { nameElement.textContent = 'KefinTweaks'; } if (versionElement) { versionElement.textContent = versionDisplay; } } else { // Handle standard card structure const cardTextElements = card.querySelectorAll('.cardText'); if (cardTextElements.length > 0) { // Update first cardText (plugin name with version) const nameElement = cardTextElements[0]; const versionSpan = nameElement.querySelector('.cardText-secondary'); if (versionSpan) { // Update existing version span versionSpan.textContent = versionDisplay; // Update the name part (text before the span) if (nameElement.firstChild && nameElement.firstChild.nodeType === Node.TEXT_NODE) { nameElement.firstChild.textContent = 'KefinTweaks'; } else { // Insert name text before the version span nameElement.insertBefore(document.createTextNode('KefinTweaks'), versionSpan); } } else { // If no secondary span exists, create one nameElement.innerHTML = `KefinTweaks${versionDisplay}`; } } // Determine status text const statusText = root ? 'Status Active' : 'Click to Install'; // Update status text (usually last cardText) if (cardTextElements.length > 0) { cardTextElements[cardTextElements.length - 1].textContent = statusText; } else { // Add status if it doesn't exist const cardFooter = card.querySelector('.cardFooter'); if (cardFooter) { const statusElement = document.createElement('div'); statusElement.className = 'cardText'; statusElement.textContent = statusText; cardFooter.appendChild(statusElement); } } } // Find and replace the anchor/link with a button that opens the modal // For Material-UI cards, the anchor might be structured differently let anchorElement = null; if (isMuiCard) { // For Material-UI cards, look for anchor in various possible locations anchorElement = card.querySelector('a[href]') || card.querySelector('.MuiCardActionArea') || card.querySelector('a'); } else { // For standard cards, look for .cardImageContainer anchorElement = card.querySelector('.cardImageContainer'); } if (anchorElement) { // Remove existing link attributes anchorElement.removeAttribute('href'); anchorElement.removeAttribute('data-url'); anchorElement.removeAttribute('target'); // Convert to button if it's not already if (anchorElement.tagName.toLowerCase() !== 'button') { const button = document.createElement('button'); // Copy all attributes (except link-specific ones) Array.from(anchorElement.attributes).forEach(attr => { if (attr.name !== 'href' && attr.name !== 'data-url' && attr.name !== 'target') { button.setAttribute(attr.name, attr.value); } }); button.className = anchorElement.className; // For Material-UI, preserve Mui classes but add button behavior if (isMuiCard) { button.setAttribute('type', 'button'); // Preserve MuiCardActionArea if it exists if (anchorElement.classList.contains('MuiCardActionArea')) { button.classList.add('MuiCardActionArea'); } } else { button.setAttribute('is', 'emby-linkbutton'); button.style.margin = '0'; button.style.padding = '0'; } // Copy all child nodes while (anchorElement.firstChild) { button.appendChild(anchorElement.firstChild); } // Replace the element anchorElement.parentNode.replaceChild(button, anchorElement); button.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openKefinTweaksSourceModal(); }; } else { // It's already a button, just update onclick anchorElement.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openKefinTweaksSourceModal(); }; } // Replace any existing img tag with icon div let img = card.querySelector('img'); if (img) { img.src = 'https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/logo.png'; img.style.width = 'auto'; img.style.height = '100%'; img.parentNode.style.background = 'linear-gradient(to bottom, #202020, #101010)'; } else { img = document.createElement('div'); img.className = 'defaultCardBackground'; // give the gradient some color img.style.background = 'url(https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/logo.png), linear-gradient(to bottom, #202020, #101010)'; img.style.backgroundSize = 'contain'; img.style.backgroundPosition = 'center'; img.style.backgroundRepeat = 'no-repeat'; img.style.width = '100%'; img.style.height = '100%'; img.style.overflow = 'hidden'; img.style.display = 'flex'; } let defaultCardBackground = card.querySelector('.defaultCardBackground') ?? card.querySelector('.MuiCardMedia-root'); if (img && defaultCardBackground) { // Replace the defaultCardBackground with the img defaultCardBackground.parentNode.replaceChild(img, defaultCardBackground); } } // Find and handle menu button (btnCardMenu) if it exists const menuButton = card.querySelector('.btnCardMenu'); if (menuButton) { // Remove existing click handlers by cloning const clonedMenuButton = menuButton.cloneNode(true); menuButton.parentNode.replaceChild(clonedMenuButton, menuButton); // Add click handler to open modal clonedMenuButton.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openKefinTweaksSourceModal(); }; } // Append only if still missing (guards against concurrent async completions) if (ensureSingleKefinTweaksPluginCard()) { return; } frontEndPlugins.appendChild(card); }).catch(err => { console.error('[KefinTweaks Installer] Error getting config for card:', err); }).finally(() => { pluginCardAddInProgress = false; }); } // Try to render the Front End Plugins card when on the plugins page async function tryRenderPluginsPage(retryCount = 0) { const maxRetries = 50; // ~5s (50 * 100ms) if (retryCount === 0) { tryRenderToken += 1; } const token = tryRenderToken; const hash = window.location.hash || ''; if (!hash.includes('dashboard/plugins')) { return; } const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); let installedPlugins = pluginsPage ? pluginsPage.querySelector('.installedPlugins') : null; if (!installedPlugins && pluginsPage) { // Support for Jellyfin 10.11.X installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div'); } if (!pluginsPage || !installedPlugins) { if (retryCount < maxRetries) { setTimeout(() => { if (token !== tryRenderToken) { return; } tryRenderPluginsPage(retryCount + 1); }, 100); } return; } ensureSingleKefinTweaksPluginCard(); if (!(await isAdmin())) { return; } if (token !== tryRenderToken) { return; } addKefinTweaksPluginCard(installedPlugins); } // Hook into Emby.Page.onViewShow function setupOnViewShow() { if (!(window.Emby && window.Emby.Page)) { setTimeout(setupOnViewShow, 100); return; } if (window.Emby.Page._kefinTweaksOnViewShowHooked) { return; } window.Emby.Page._kefinTweaksOnViewShowHooked = true; const originalOnViewShow = window.Emby.Page.onViewShow; window.Emby.Page.onViewShow = function(...args) { if (originalOnViewShow) { try { originalOnViewShow.apply(this, args); } catch (err) { console.warn('[KefinTweaks Installer] Error in original onViewShow:', err); } } tryRenderPluginsPage(); }; console.log('[KefinTweaks Installer] Hooked into Emby.Page.onViewShow'); // Cover direct URL / reload when onViewShow already fired before the hook tryRenderPluginsPage(); } function injectPluginCardCss() { if (document.getElementById('kefinTweaksPluginCardCss')) { return; } const css = document.createElement('style'); css.id = 'kefinTweaksPluginCardCss'; css.textContent = ` [data-id="kefinTweaksPlugin"] .cardImageContainer::after, [data-id="kefinTweaksPlugin"] .MuiButtonBase-root.MuiCardActionArea-root::after { content: 'KefinTweaks'; position: absolute; font-size: 2em; top: 50%; transform: translateY(-50%); } `; document.head.appendChild(css); } function shouldInjectCustomPageStyles(config) { if (!config) return false; const scripts = config.scripts || {}; const links = config.customMenuLinks; return scripts.watchlist === true || scripts.games === true || (Array.isArray(links) && links.length > 0); } function injectCustomPageStyles(config) { if (!shouldInjectCustomPageStyles(config)) return; const css = ` #reactRoot.kefin-custom-page-active .skinBody #fallbackPage { display: none; } #reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready]) > * { display: none; } #reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready])::after { content: ''; display: inline-block; width: 20px; height: 20px; border: 3px solid #f3f3f3; border-top: 3px solid #4ecdc4; border-radius: 50%; animation: spin 1s linear infinite; position: relative; left: 50%; transform: translateX(-50%); top: 1em; } #fallbackPage[data-kefin-fallback-ready]::after { display: none !important; content: none !important; } #reactRoot.kefin-custom-page-active .backdropImage { background: none !important; } header.MuiPaper-root + main.MuiBox-root .customPage { margin-top: 3rem; } .layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2), .layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img { display: none; } .layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after { content: '\\e88a'; font-family: 'Material Icons'; font-size: 1.2rem; } .layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child { font-size: 0px; } main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { padding-top: 1rem !important; } .layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { padding-top: 5rem !important; } `; window.KefinTweaksCustomPageStyles = css; let style = document.getElementById('kefin-custom-page-styles'); if (!style) { style = document.createElement('style'); style.id = 'kefin-custom-page-styles'; (document.head || document.documentElement).appendChild(style); } style.textContent = css; } // Initialize the installer (no login/admin gate — admin is checked when rendering the card) function initialize() { checkAndLoadInjector(); injectPluginCardCss(); setupOnViewShow(); } // Resolve @latest, @main, or @experimental to actual version/commit hash (same as injector.js) async function resolveRootVersion(root) { const latestMatch = root.match(/@latest(\/|$)/); const mainMatch = root.match(/@main(\/|$)/); const experimentalMatch = root.match(/@experimental(\/|$)/); if (latestMatch) { try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); if (response.ok) { const data = await response.json(); if (data && data.tag_name) { const versionTag = data.tag_name.startsWith('v') ? data.tag_name : 'v' + data.tag_name; return root.replace('@latest', `@${versionTag}`); } console.warn('[KefinTweaks Installer] Could not fetch latest version, using @latest'); return root; } console.warn('[KefinTweaks Installer] Failed to fetch latest version, using @latest'); return root; } catch (error) { console.warn('[KefinTweaks Installer] Error fetching latest version:', error); return root; } } if (mainMatch) { try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/main'); if (response.ok) { const commit = await response.json(); if (commit && commit.sha) { return root.replace('@main', `@${commit.sha}`); } console.warn('[KefinTweaks Installer] Could not fetch commit hash, using @main'); return root; } console.warn('[KefinTweaks Installer] Failed to fetch commit hash, using @main'); return root; } catch (error) { console.warn('[KefinTweaks Installer] Error fetching commit hash:', error); return root; } } if (experimentalMatch) { try { const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/experimental'); if (response.ok) { const commit = await response.json(); if (commit && commit.sha) { return root.replace('@experimental', `@${commit.sha}`); } console.warn('[KefinTweaks Installer] Could not fetch experimental commit hash, using @experimental'); return root; } console.warn('[KefinTweaks Installer] Failed to fetch experimental commit hash, using @experimental'); return root; } catch (error) { console.warn('[KefinTweaks Installer] Error fetching experimental commit hash:', error); return root; } } return root; } // Load injector.js from a specific root URL (fallback when bake is absent). // GitHub floating resolve + persist only for admins; non-admins use pinned/symbolic as-is. async function loadInjectorFromRoot(root) { if (!root || root === '') { console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty'); return false; } const rootUrl = normalizeKefinRootUrl(root); const admin = await isAdmin(); let resolvedRoot = rootUrl; if (isFloatingKefinRoot(rootUrl)) { if (admin) { resolvedRoot = normalizeKefinRootUrl(await resolveRootVersion(rootUrl)); } else { const pinned = normalizeKefinRootUrl(window.KefinTweaksConfig?.kefinTweaksRootResolved || ''); resolvedRoot = pinned || rootUrl; console.log('[KefinTweaks Installer] Non-admin: skipping GitHub resolve; using', resolvedRoot); } } if (resolvedRoot !== rootUrl) { console.log('[KefinTweaks Installer] Resolved root from', rootUrl, 'to', resolvedRoot); } // Admin: persist tip move; if replacing a previous pin, countdown reload (no tip injector this session) if (admin && isFloatingKefinRoot(rootUrl)) { try { const expected = normalizeKefinRootUrl(resolvedRoot); if (!window.KefinTweaksConfig) { window.KefinTweaksConfig = {}; } const symbolicRoot = window.KefinTweaksConfig.kefinTweaksRoot || rootUrl; const current = normalizeKefinRootUrl(window.KefinTweaksConfig.kefinTweaksRootResolved || ''); if (expected && expected !== current) { window.KefinTweaksConfig.kefinTweaksRootResolved = expected; console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected); if (isLoggedInForInjectorWrite()) { const persisted = await saveConfigToJavaScriptInjector(window.KefinTweaksConfig); if (persisted && current) { // Prior pin existed — force reload so scripts/Config stay aligned maybeQueueResolvedRootUpdateToast({ symbolicRoot, previousResolved: current, nextResolved: expected, persisted: true }); return true; } if (persisted && !current) { // First pin: continue and load injector from the new resolved URL console.log('[KefinTweaks Installer] First resolved pin persisted; loading injector.js'); } } else { console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist'); } } } catch (e) { console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e); } } if (window.__kefinTipReloadScheduled) { console.log('[KefinTweaks Installer] Tip reload scheduled; skipping injector.js load'); return true; } const injectorUrl = `${resolvedRoot}injector.js`; // Check if injector is already loaded from this URL const existingScript = document.querySelector(`script[src="${injectorUrl}"]`); if (existingScript) { console.log('[KefinTweaks Installer] Injector already loaded from:', injectorUrl); return true; } console.log('[KefinTweaks Installer] Loading injector.js from:', injectorUrl); return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = injectorUrl; script.async = true; script.onload = () => { console.log('[KefinTweaks Installer] Injector loaded successfully'); resolve(true); }; script.onerror = (error) => { console.error('[KefinTweaks Installer] Failed to load injector:', error); reject(new Error(`Failed to load injector.js from ${injectorUrl}`)); }; document.head.appendChild(script); }); } // Check if config exists; auto-create bake when missing (admin); never dual-inject when bake attached async function checkAndLoadInjector() { try { // Prefer in-page Config (already injected). Avoid /Plugins GET on normal page load for non-admins. let config = window.KefinTweaksConfig || null; if (!config) { const admin = await isAdmin(); if (admin) { await waitForLoginForInjectorWrite(); config = await getKefinTweaksConfig(); } } // Before injector/utils: hide fallback page chrome for custom routes injectCustomPageStyles(config); const root = config?.kefinTweaksRoot || ''; if (!root || root === '') { console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.'); return; } // Bake already on page: never load live injector.js (prevents dual SHA trees) if (isKefinInjectorAttachedOnPage()) { if (!(await isAdmin())) { console.log('[KefinTweaks Installer] KefinTweaks-injector attached; non-admin skipping live injector.js'); return; } console.log('[KefinTweaks Installer] KefinTweaks-injector attached; admin tip sync without live inject'); await handleAdminAttachedBakeTipSync(config); return; } if (await isAdmin()) { await waitForLoginForInjectorWrite(); const ensured = await ensureKefinTweaksInjectorEntry(config); if (ensured.ok && ensured.plan) { window.KefinTweaksScriptsPreloaded = true; window.KefinTweaksInjectorAttached = true; if (window.KefinTweaksLoader) { window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS); window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets); } try { document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { detail: { stamp: ensured.plan.stamp, timestamp: new Date().toISOString(), autoCreated: true } })); } catch (e) { /* ignore */ } console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create'); // Tip advance inside ensure may have scheduled reload; never also load tip injector.js if (window.__kefinTipReloadScheduled) return; return; } console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js'); } else { console.log('[KefinTweaks Installer] KefinTweaks-injector not on page (non-admin); loading injector.js'); } await loadInjectorFromRoot(root); } catch (error) { console.error('[KefinTweaks Installer] Error checking config:', error); } } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { initialize(); }); } else { initialize(); } console.log('[KefinTweaks Installer] Installer script loaded'); })();