// 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) { if (rootPredatesKefinLoader(root)) { return Promise.reject(new Error('Pin predates kefinTweaks-loader.js')); } 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); }); } /** True for bakeds that early-return on official CDN (isOfficialCdn gate). */ function isBrickedOfficialCdnBakeScript(scriptText) { return String(scriptText || '').includes('isOfficialCdn'); } function getLatestSymbolicRoot() { return normalizeKefinRootUrl(JSDELIVR_BASE + '@latest/'); } /** * Load loader from root even if another KefinTweaksLoader is already sticky. * Used by bricked-bake heal so a pinned broken tag cannot supply the builder. */ function loadKefinTweaksLoaderFromRootForced(root) { const normalized = root.endsWith('/') ? root : root + '/'; const url = `${normalized}scripts/kefinTweaks-loader.js`; const existingForUrl = document.querySelector(`script[src="${url}"]`); if (window.KefinTweaksLoader && existingForUrl) { return Promise.resolve(window.KefinTweaksLoader); } try { delete window.KefinTweaksLoader; } catch (e) { window.KefinTweaksLoader = undefined; } return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.async = false; script.onload = () => { if (window.KefinTweaksLoader) resolve(window.KefinTweaksLoader); else reject(new Error('KefinTweaksLoader missing after load: ' + url)); }; 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); } } // --- Latest release update prompt (admin; runs from plugin so bake-attached path still checks) --- function normalizeReleaseTag(tag) { const t = String(tag || '').trim(); if (!t) return ''; return t.startsWith('v') || t.startsWith('V') ? `v${t.slice(1)}` : `v${t}`; } function normalizeUpdateState(config) { if (!config || typeof config !== 'object') return { dismissed: true, latestVersion: '' }; if (!config.updateState || typeof config.updateState !== 'object') { config.updateState = { dismissed: true, latestVersion: '' }; } else { if (typeof config.updateState.dismissed !== 'boolean') { config.updateState.dismissed = true; } if (typeof config.updateState.latestVersion !== 'string') { config.updateState.latestVersion = ''; } else { config.updateState.latestVersion = normalizeReleaseTag(config.updateState.latestVersion) || config.updateState.latestVersion; } } return config.updateState; } function isGithubJsdelivrKefinRoot(root) { return /cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@/i.test(String(root || '')); } function isSymbolicLatestRoot(root) { return /@latest(\/|$)/i.test(String(root || '')); } function parseGithubRootRef(root) { const s = String(root || ''); const floating = s.match(/@(latest|main|master|experimental)(\/|$)/i); if (floating) { const name = floating[1].toLowerCase(); return { kind: name === 'master' ? 'main' : name, value: name === 'master' ? 'main' : name }; } const semver = s.match(/@(v?\d+\.\d+\.\d+)(\/|$)/i); if (semver) { return { kind: 'semver', value: normalizeReleaseTag(semver[1]) }; } const sha = s.match(/@([0-9a-f]{40})(\/|$)/i); if (sha) { return { kind: 'sha', value: sha[1].toLowerCase() }; } return null; } /** GitHub release tags before v0.5.0 do not ship scripts/kefinTweaks-loader.js. */ function rootPredatesKefinLoader(root) { if (!isGithubJsdelivrKefinRoot(root)) return false; const ref = parseGithubRootRef(root); if (!ref || ref.kind !== 'semver') return false; const parts = String(ref.value).replace(/^v/i, '').split('.').map((n) => parseInt(n, 10)); if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return false; const major = parts[0]; const minor = parts[1]; return major === 0 && minor < 5; } function parseShaFromRoot(root) { const m = String(root || '').match(/@([0-9a-f]{40})(\/|$)/i); return m ? m[1].toLowerCase() : null; } async function waitForGlobal(getter, timeoutMs = 15000, intervalMs = 250) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const value = getter(); if (value) return value; } catch (_) { /* ignore */ } await new Promise((r) => setTimeout(r, intervalMs)); } return null; } async function persistConfigFromUpdateCheck(config) { window.KefinTweaksConfig = config; try { // Prefer utils when baked/live scripts have loaded; fall back to plugin saver const saveFn = await waitForGlobal( () => window.KefinTweaksUtils?.saveConfigToJavaScriptInjector, 8000 ); if (saveFn) { return !!(await saveFn(config)); } return !!(await saveConfigToJavaScriptInjector(config)); } catch (e) { console.warn('[KefinTweaks Installer] Failed to persist updateState:', e); return false; } } async function fetchGithubJson(url) { const response = await fetch(url); if (!response.ok) return null; return response.json(); } async function fetchLatestGithubRelease() { const data = await fetchGithubJson('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); if (!data?.tag_name) return null; const publishedAt = data.published_at ? new Date(data.published_at) : null; if (!publishedAt || Number.isNaN(publishedAt.getTime())) return null; return { tag: normalizeReleaseTag(data.tag_name), publishedAt }; } async function fetchReleasePublishedAt(tag) { const normalized = normalizeReleaseTag(tag); if (!normalized) return null; const data = await fetchGithubJson( `https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/${encodeURIComponent(normalized)}` ); if (!data?.published_at) return null; const d = new Date(data.published_at); return Number.isNaN(d.getTime()) ? null : d; } async function fetchCommitDate(shaOrRef) { if (!shaOrRef) return null; const data = await fetchGithubJson( `https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/${encodeURIComponent(shaOrRef)}` ); const iso = data?.commit?.committer?.date || data?.commit?.author?.date; if (!iso) return null; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } async function resolveInstalledCommitDate(config, branchName) { const resolvedSha = parseShaFromRoot(config?.kefinTweaksRootResolved); if (resolvedSha) { const fromResolved = await fetchCommitDate(resolvedSha); if (fromResolved) return fromResolved; } return fetchCommitDate(branchName || 'main'); } /** * True when the install should treat GitHub Latest as a "new update" vs current pin/tip. */ async function isGithubInstallBehindLatest(root, config, latest) { const ref = parseGithubRootRef(root); if (!ref) return true; if (ref.kind === 'semver') { if (ref.value === latest.tag) return false; const installedPublished = await fetchReleasePublishedAt(ref.value); if (!installedPublished) return true; return installedPublished.getTime() < latest.publishedAt.getTime(); } if (ref.kind === 'sha') { const commitDate = await fetchCommitDate(ref.value); if (!commitDate) return true; return commitDate.getTime() < latest.publishedAt.getTime(); } if (ref.kind === 'main' || ref.kind === 'experimental') { const tipDate = await resolveInstalledCommitDate(config, ref.kind); if (!tipDate) return true; // Tip newer than Latest ⇒ following branch tip, not a release-update prompt if (tipDate.getTime() >= latest.publishedAt.getTime()) return false; return true; } return true; } function buildConcreteLatestRoot(latestTag) { const tag = normalizeReleaseTag(latestTag); return `${JSDELIVR_BASE}@${tag}/`; } function ensureInstallerToastStyles() { if (document.getElementById('kefin-installer-toast-styles')) return; const style = document.createElement('style'); style.id = 'kefin-installer-toast-styles'; style.textContent = ` .toast.kefin-toast-rich { display: flex; align-items: center; gap: 0.75em; max-width: min(28rem, calc(100vw - 2rem)); } .toast.kefin-toast-rich .kefin-toast-message { flex: 1 1 auto; min-width: 0; line-height: 1.35; } .toast.kefin-toast-rich .kefin-toast-link { color: inherit; text-decoration: underline; cursor: pointer; } .toast.kefin-toast-rich.kefin-toast-with-buttons { flex-wrap: wrap; align-items: flex-start; } .toast.kefin-toast-rich .kefin-toast-buttons { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: 0.5em; margin-top: 0.15em; } .toast.kefin-toast-rich .kefin-toast-text-btn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0.35em 0.9em; margin: 0; border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 4px; background: rgba(255, 255, 255, 0.08); color: inherit; font: inherit; font-weight: 500; cursor: pointer; } .toast.kefin-toast-rich .kefin-toast-text-btn:hover { background: rgba(255, 255, 255, 0.16); } `; (document.head || document.documentElement).appendChild(style); } /** * Update prompt toast. Drawn here so every pin gets Update and Dismiss, * including releases whose toaster.js ignores the button argument. */ function showInstallerToast(message, options) { if (!message) return null; const buttons = Array.isArray(options?.buttons) ? options.buttons.filter((b) => b && typeof b.label === 'string' && typeof b.onClick === 'function') : []; const link = options?.link; ensureInstallerToastStyles(); let container = document.querySelector('body > .toastContainer:last-child') || document.querySelector('.toastContainer'); let customContainer = false; if (!container) { container = document.createElement('div'); container.className = 'toastContainer'; (document.body || document.documentElement).appendChild(container); customContainer = true; } const existing = container.querySelector('.toast'); if (existing) existing.remove(); const toastElement = document.createElement('div'); toastElement.className = 'toast kefin-toast-rich' + (buttons.length ? ' kefin-toast-with-buttons' : ''); const messageEl = document.createElement('span'); messageEl.className = 'kefin-toast-message'; if (link && typeof link.onClick === 'function') { const anchor = document.createElement('a'); anchor.className = 'kefin-toast-link'; anchor.href = link.href || '#'; anchor.textContent = message; anchor.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); try { link.onClick(); } catch (e) { console.error('[KefinTweaks Installer] Toast link failed:', e); } }); messageEl.appendChild(anchor); } else { messageEl.textContent = message; } toastElement.appendChild(messageEl); if (buttons.length) { const row = document.createElement('div'); row.className = 'kefin-toast-buttons'; buttons.forEach((spec) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'kefin-toast-text-btn'; btn.textContent = spec.label; btn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); try { spec.onClick(); } catch (e) { console.error('[KefinTweaks Installer] Toast button failed:', e); } }); row.appendChild(btn); }); toastElement.appendChild(row); } container.appendChild(toastElement); void toastElement.offsetHeight; toastElement.classList.add('toastVisible'); const dismiss = () => { if (!toastElement.parentNode) return; toastElement.classList.add('toastHide'); setTimeout(() => { if (toastElement.parentNode) toastElement.parentNode.removeChild(toastElement); if (customContainer && container && !container.querySelector('.toast')) { container.remove(); } }, 3000); }; if (options?.autoHide) { const seconds = parseFloat(String(options.duration || '8').replace('s', '')); setTimeout(dismiss, Number.isNaN(seconds) ? 8000 : seconds * 1000); } return { dismiss, element: toastElement }; } async function showPinnedVersionUpdateToast(config, latestTag) { const tag = normalizeReleaseTag(latestTag); const githubInstall = isGithubJsdelivrKefinRoot(config?.kefinTweaksRoot); const dismissAndSave = async () => { normalizeUpdateState(config); config.updateState.dismissed = true; if (tag) config.updateState.latestVersion = tag; await persistConfigFromUpdateCheck(config); }; const releaseNotesUrl = tag ? `https://github.com/ranaldsgift/KefinTweaks/releases/tag/${encodeURIComponent(tag)}` : 'https://github.com/ranaldsgift/KefinTweaks/releases/latest'; const versionLabel = tag || 'a new version'; const openReleaseNotes = () => { try { window.open(releaseNotesUrl, '_blank', 'noopener'); } catch (_) { /* ignore */ } }; const updateMessage = `KefinTweaks ${versionLabel} is available!`; const handle = showInstallerToast(updateMessage, { link: { href: releaseNotesUrl, onClick: openReleaseNotes }, buttons: [ { label: 'Update', onClick: async () => { try { normalizeUpdateState(config); config.updateState.dismissed = true; if (tag) config.updateState.latestVersion = tag; if (githubInstall && tag) { const nextRoot = buildConcreteLatestRoot(tag); config.kefinTweaksRoot = nextRoot; config.kefinTweaksRootResolved = nextRoot; await persistConfigFromUpdateCheck(config); handle?.dismiss?.(); showInstallerToast( 'KefinTweaks updated. Please refresh the page for the changes to take effect.', { autoHide: true, duration: '8' } ); } else { await persistConfigFromUpdateCheck(config); handle?.dismiss?.(); openReleaseNotes(); } } catch (e) { console.error('[KefinTweaks Installer] Update action failed:', e); } } }, { label: 'Dismiss', onClick: async () => { try { await dismissAndSave(); handle?.dismiss?.(); } catch (e) { console.error('[KefinTweaks Installer] Dismiss action failed:', e); } } } ] }); } async function checkPinnedVersionUpdate(config) { const cfg = config || window.KefinTweaksConfig; const root = cfg?.kefinTweaksRoot; if (!root) { console.log('[KefinTweaks Installer] Update check skipped: no kefinTweaksRoot'); return; } // Floating @latest already auto-resolves to GitHub Latest — skip this prompt flow if (isSymbolicLatestRoot(root)) { normalizeUpdateState(cfg); console.log('[KefinTweaks Installer] Update check skipped: symbolic @latest'); return; } const state = normalizeUpdateState(cfg); let latest; try { latest = await fetchLatestGithubRelease(); } catch (e) { console.warn('[KefinTweaks Installer] Failed to fetch latest release:', e); return; } if (!latest?.tag) { console.warn('[KefinTweaks Installer] Update check: no latest release tag'); return; } console.log( '[KefinTweaks Installer] Update check: known=', state.latestVersion || '(none)', 'github=', latest.tag, 'publishedAt=', latest.publishedAt?.toISOString?.() || latest.publishedAt ); const known = normalizeReleaseTag(state.latestVersion); if (known !== latest.tag) { let markNew = false; if (isGithubJsdelivrKefinRoot(root)) { try { markNew = await isGithubInstallBehindLatest(root, cfg, latest); } catch (e) { console.warn('[KefinTweaks Installer] Install-vs-latest compare failed; treating as new:', e); markNew = true; } } else { markNew = true; } console.log('[KefinTweaks Installer] Update check: markNew=', markNew); if (markNew) { state.dismissed = false; state.latestVersion = latest.tag; await persistConfigFromUpdateCheck(cfg); } else { state.latestVersion = latest.tag; await persistConfigFromUpdateCheck(cfg); } } if (!state.dismissed) { await showPinnedVersionUpdateToast(cfg, latest.tag); } } /** * Run once per page load from admin plugin paths (including bake-attached tip sync). */ async function runPinnedVersionUpdateCheck(config) { if (window.__kefinUpdateCheckDone) return; if (!(await isAdmin())) return; window.__kefinUpdateCheckDone = true; try { await checkPinnedVersionUpdate(config || window.KefinTweaksConfig); } catch (e) { console.warn('[KefinTweaks Installer] Update check failed:', e); } } /** * Human-readable installed version for config UI (tag, short SHA, branch, or Custom). */ function getInstalledVersionLabel(config) { const cfg = config || window.KefinTweaksConfig; const root = cfg?.kefinTweaksRootResolved || cfg?.kefinTweaksRoot || ''; if (!root) return 'Unknown'; if (isSymbolicLatestRoot(root)) return 'latest'; const ref = parseGithubRootRef(root); if (!ref) { return isGithubJsdelivrKefinRoot(root) ? 'Unknown' : 'Custom'; } if (ref.kind === 'semver') return ref.value; if (ref.kind === 'sha') return ref.value.slice(0, 7); if (ref.kind === 'main' || ref.kind === 'experimental') return ref.kind; return 'Custom'; } /** * Whether GitHub Latest is newer than the current pin (ignores dismiss state). * @returns {Promise<{ available: boolean, latestTag: string }>} */ async function isUpdateAvailable(config) { const cfg = config || window.KefinTweaksConfig; const root = cfg?.kefinTweaksRoot; if (!root || isSymbolicLatestRoot(root)) { return { available: false, latestTag: '' }; } let latest; try { latest = await fetchLatestGithubRelease(); } catch (e) { console.warn('[KefinTweaks Installer] isUpdateAvailable: fetch failed:', e); return { available: false, latestTag: '' }; } if (!latest?.tag) return { available: false, latestTag: '' }; let available = false; if (isGithubJsdelivrKefinRoot(root)) { try { available = await isGithubInstallBehindLatest(root, cfg, latest); } catch (e) { console.warn('[KefinTweaks Installer] isUpdateAvailable: compare failed:', e); available = true; } } else { available = true; } return { available, latestTag: latest.tag }; } /** * Force-show the update toast (ignores updateState.dismissed). */ async function showUpdateAvailablePrompt(config, latestTag) { const cfg = config || window.KefinTweaksConfig; let tag = normalizeReleaseTag(latestTag); if (!tag) { try { const latest = await fetchLatestGithubRelease(); tag = latest?.tag || ''; } catch (e) { console.warn('[KefinTweaks Installer] showUpdateAvailablePrompt: fetch failed:', e); } } if (!tag) { console.warn('[KefinTweaks Installer] showUpdateAvailablePrompt: no latest tag'); return; } await showPinnedVersionUpdateToast(cfg, tag); } function isKefinInjectorAttachedOnPage() { return !!( window.KefinTweaksInjectorAttached || window.KefinTweaksScriptsPreloaded || window.KefinTweaksInjectorStamp ); } /** * Detect bricked official-CDN bake (isOfficialCdn early-return) and rewrite in place * using the loader from current GitHub @latest tip. User kefinTweaksRoot / assets unchanged. * @returns {Promise<{ healed: boolean, reason?: string, tip?: string }>} */ async function healBrickedOfficialCdnBake(config) { if (window.__kefinBrickedBakeHealDone) { return { healed: false, reason: 'already attempted' }; } window.__kefinBrickedBakeHealDone = true; if (!isLoggedInForInjectorWrite()) { return { healed: false, reason: 'not logged in' }; } if (!(await isAdmin())) { return { healed: false, reason: 'not admin' }; } try { const pluginId = await findJavaScriptInjectorPlugin(); if (!pluginId) { return { healed: false, reason: 'JavaScript Injector plugin not found' }; } const injectorConfig = await getJavaScriptInjectorConfig(pluginId); if (!injectorConfig) { return { healed: false, reason: 'Could not read JS Injector configuration' }; } const entry = (injectorConfig.CustomJavaScripts || []).find( (s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false ); if (!entry || !isBrickedOfficialCdnBakeScript(entry.Script)) { return { healed: false, reason: 'not bricked' }; } console.warn( '[KefinTweaks Installer] Bricked KefinTweaks-injector detected (isOfficialCdn); rebuilding with @latest loader' ); const latestSymbolic = getLatestSymbolicRoot(); const latestTip = normalizeKefinRootUrl(await resolveRootVersion(latestSymbolic)); await loadKefinTweaksLoaderFromRootForced(latestTip); const Loader = window.KefinTweaksLoader; if (!Loader || typeof Loader.buildInjectorScript !== 'function') { return { healed: false, reason: 'KefinTweaksLoader unavailable from @latest tip' }; } 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 bricked-bake heal:', e); } const planRoot = Loader.getResolvedKefinRoot ? Loader.getResolvedKefinRoot(config) : normalizeKefinRootUrl(config?.kefinTweaksRootResolved || config?.kefinTweaksRoot || ''); const plan = Loader.buildLoadPlan(config, major, { root: planRoot, configOnly: config?.enabled === false }); const injectorScriptContent = Loader.buildInjectorScript(plan); if (isBrickedOfficialCdnBakeScript(injectorScriptContent)) { console.error( '[KefinTweaks Installer] @latest loader still produces bricked bake; aborting heal' ); return { healed: false, reason: 'latest loader still bricked' }; } 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)};`; 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 { healed: false, reason: `POST failed HTTP ${response.status}: ${response.statusText}` }; } console.log( '[KefinTweaks Installer] Bricked bake rebuilt from @latest loader tip:', latestTip, '(' + ((plan.assets && plan.assets.length) || 0) + ' assets)' ); scheduleForcedTipReloadCountdown({ symbolicRoot: latestSymbolic, nextResolved: latestTip }); return { healed: true, tip: latestTip }; } catch (err) { console.warn('[KefinTweaks Installer] healBrickedOfficialCdnBake failed:', err); return { healed: false, reason: err?.message || String(err) }; } } 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?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; window.KefinTweaksInstaller = window.KefinTweaksInstaller || {}; window.KefinTweaksInstaller.getInstalledVersionLabel = getInstalledVersionLabel; window.KefinTweaksInstaller.isUpdateAvailable = isUpdateAvailable; window.KefinTweaksInstaller.showUpdateAvailablePrompt = showUpdateAvailablePrompt; // Also expose flat helpers for simple call sites window.getKefinTweaksInstalledVersionLabel = getInstalledVersionLabel; window.isKefinTweaksUpdateAvailable = isUpdateAvailable; window.showKefinTweaksUpdateAvailablePrompt = showUpdateAvailablePrompt; // 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 = '