// 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?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 = '