mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
3299 lines
137 KiB
JavaScript
3299 lines
137 KiB
JavaScript
// 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 });
|
|
}
|
|
|
|
/**
|
|
* Inject plan assets the attached bake did not include. Path matching in
|
|
* applyAssetsToDocument skips files already loaded from another root.
|
|
* Does not write the injector entry.
|
|
*/
|
|
async function fillMissingBakedAssets(config) {
|
|
const rootRaw = (config && (config.kefinTweaksRootResolved || config.kefinTweaksRoot)) || '';
|
|
const root = normalizeKefinRootUrl(rootRaw);
|
|
if (!root || root === '/' || rootPredatesKefinLoader(root)) return null;
|
|
await loadKefinTweaksLoaderFromRootForced(root);
|
|
const Loader = window.KefinTweaksLoader;
|
|
if (!Loader || typeof Loader.buildLoadPlan !== 'function' || typeof Loader.applyAssetsToDocument !== 'function') {
|
|
return null;
|
|
}
|
|
if (typeof Loader.ensureKefinTweaksApi === 'function') {
|
|
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
|
}
|
|
let major = null;
|
|
try {
|
|
major = await window.KefinTweaks.getJellyfinMajorVersion();
|
|
} catch (e) { /* ignore */ }
|
|
const planRoot = Loader.getResolvedKefinRoot
|
|
? Loader.getResolvedKefinRoot(config)
|
|
: root;
|
|
const plan = Loader.buildLoadPlan(config, major, {
|
|
root: planRoot,
|
|
configOnly: config.enabled === false
|
|
});
|
|
Loader.applyAssetsToDocument(plan.assets);
|
|
console.log('[KefinTweaks Installer] Gap-filled baked assets from', planRoot);
|
|
return plan;
|
|
}
|
|
|
|
/**
|
|
* Admin + bake already on page: tip resolve/persist/sync without loading live injector.js.
|
|
* Tip advanced → gap-fill, persist Config+bake, then forced reload.
|
|
* Tip unchanged → gap-fill and stamp sync.
|
|
*/
|
|
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 {
|
|
await fillMissingBakedAssets(config);
|
|
} catch (e) {
|
|
console.warn('[KefinTweaks Installer] Gap fill before tip reload failed:', e);
|
|
}
|
|
|
|
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 — gap-fill missing assets, then stamp sync (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
|
|
});
|
|
if (typeof Loader.applyAssetsToDocument === 'function') {
|
|
Loader.applyAssetsToDocument(plan.assets);
|
|
console.log('[KefinTweaks Installer] Gap-filled baked assets from', planRoot);
|
|
}
|
|
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 = `<p>${message}</p>`;
|
|
|
|
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 = `
|
|
<p>Thanks so much for installing KefinTweaks!</p>
|
|
<p>KefinTweaks is not maintained by the Jellyfin team, and as a result you are encouraged to seek support from me directly.</p>
|
|
<p>Sadly, there is <i>currently</i> no place suitable for discussion of plugins built by community members or fan-made projects in the official Jellyfin Discord, so please visit the <a href="https://discord.gg/v7P9CAvCKZ" target="_blank" style="color: #00a4dc; text-decoration: underline;">Jellyfin Community Discord</a> to find me (username: HighImKevin) and other users who would be happy to help you out.</p>
|
|
<p>Please also feel free to report bugs and request features from the <a href="https://github.com/ranaldsgift/KefinTweaks/issues" target="_blank" style="color: #00a4dc; text-decoration: underline;">Issues</a> page. The strength of this plugin relies on awesome community members like you, so thanks for using KefinTweaks!</p>
|
|
`;
|
|
} else {
|
|
// Update message with refresh instruction
|
|
content.innerHTML = `
|
|
<p>KefinTweaks configuration has been successfully updated. Please refresh your page for the changes to take effect!</p>
|
|
`;
|
|
}
|
|
|
|
// 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', '<p>You must be an administrator to configure KefinTweaks.</p>');
|
|
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 = `
|
|
<option value="github" ${sourceInfo.sourceType === 'github' ? 'selected' : ''}>KefinTweaks GitHub</option>
|
|
<option value="custom" ${sourceInfo.sourceType === 'custom' ? 'selected' : ''}>${SELF_HOSTED_NAME}</option>
|
|
`;
|
|
|
|
// 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 =>
|
|
`<option value="${v}" ${v === sourceInfo.version ? 'selected' : ''}>${v}</option>`
|
|
).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', '<p>Please enter a valid URL for your self-hosted location.</p>');
|
|
return;
|
|
}
|
|
|
|
let commitHash = '';
|
|
if (sourceType === 'github' && source === SPECIFIC_BUILD_NAME) {
|
|
commitHash = (commitHashInput.value || '').trim().toLowerCase();
|
|
if (!isFullCommitSha(commitHash)) {
|
|
showAlertModal(
|
|
'Invalid Commit Hash',
|
|
'<p>Please enter a full 40-character git commit SHA for Specific Build.</p>'
|
|
);
|
|
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', `<p>Error saving configuration: ${error.message}</p>`);
|
|
}
|
|
};
|
|
|
|
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?<br><br>This will disable the installer and backup your configuration.',
|
|
async () => {
|
|
try {
|
|
await uninstallKefinTweaks();
|
|
// Remove the plugin card
|
|
removeKefinTweaksPluginCard();
|
|
showAlertModal(
|
|
'KefinTweaks Uninstalled',
|
|
'<p>KefinTweaks has been uninstalled. The installer has been disabled and your configuration has been backed up.<br><br>Please refresh your page.<br><br>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',
|
|
`<p>Error uninstalling KefinTweaks: ${error.message}</p>`
|
|
);
|
|
}
|
|
},
|
|
() => {
|
|
// 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<span class="cardText cardText-secondary">${versionDisplay}</span>`;
|
|
}
|
|
}
|
|
|
|
// 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 = '<h2 class="sectionTitle">Front End Plugins</h2>';
|
|
heading.style.marginTop = '2em';
|
|
|
|
// Create the frontEndPlugins container
|
|
frontEndPlugins = document.createElement('div');
|
|
frontEndPlugins.className = 'frontEndPlugins itemsContainer vertical-wrap';
|
|
|
|
// Insert heading and container after installedPlugins
|
|
if (installedPlugins.nextSibling) {
|
|
installedPlugins.parentNode.insertBefore(heading, installedPlugins.nextSibling);
|
|
installedPlugins.parentNode.insertBefore(frontEndPlugins, heading.nextSibling);
|
|
} else {
|
|
installedPlugins.parentNode.appendChild(heading);
|
|
installedPlugins.parentNode.appendChild(frontEndPlugins);
|
|
}
|
|
}
|
|
|
|
// Find an existing plugin card to clone
|
|
// Try multiple selectors to find a plugin card
|
|
const existingCard = installedPlugins.querySelector('.card[data-id]') ||
|
|
installedPlugins.querySelector(':first-child') ||
|
|
installedPlugins.firstElementChild;
|
|
|
|
if (!existingCard) {
|
|
if (pluginCardObserver) {
|
|
// Already waiting for native plugin cards; keep in-progress lock
|
|
return;
|
|
}
|
|
|
|
console.log('[KefinTweaks Installer] No existing plugin card found, setting up MutationObserver');
|
|
|
|
pluginCardObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.addedNodes.length > 0) {
|
|
const cardToClone = installedPlugins.querySelector('.card[data-id]') ||
|
|
installedPlugins.querySelector(':first-child') ||
|
|
installedPlugins.firstElementChild;
|
|
|
|
if (cardToClone) {
|
|
console.log('[KefinTweaks Installer] Plugin card detected, disconnecting observer');
|
|
pluginCardObserver.disconnect();
|
|
pluginCardObserver = null;
|
|
pluginCardAddInProgress = false;
|
|
addKefinTweaksPluginCard(installedPlugins);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
pluginCardObserver.observe(installedPlugins, {
|
|
childList: true,
|
|
subtree: false
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (pluginCardObserver) {
|
|
pluginCardObserver.disconnect();
|
|
pluginCardObserver = null;
|
|
pluginCardAddInProgress = false;
|
|
console.warn('[KefinTweaks Installer] MutationObserver timeout: No plugin cards appeared after 10 seconds');
|
|
}
|
|
}, 10000);
|
|
|
|
return;
|
|
}
|
|
|
|
// Get current config to determine version/status
|
|
getKefinTweaksConfig().then(async config => {
|
|
const root = config?.kefinTweaksRoot || '';
|
|
const sourceInfo = parseKefinTweaksSource(root);
|
|
|
|
// Determine version display text
|
|
let versionDisplay = 'Installation Pending';
|
|
if (root) {
|
|
if (sourceInfo.sourceType === 'custom') {
|
|
versionDisplay = SELF_HOSTED_NAME;
|
|
} else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
|
|
versionDisplay = formatSpecificBuildDisplay(sourceInfo);
|
|
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
|
|
versionDisplay = DEVELOPMENT_NAME;
|
|
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
|
|
// Fetch the actual latest version number
|
|
try {
|
|
const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const versionNumber = data.tag_name.replace(/^v/, '');
|
|
versionDisplay = `${versionNumber} (${LATEST_RELEASE_NAME})`;
|
|
} else {
|
|
versionDisplay = LATEST_RELEASE_NAME;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[KefinTweaks Installer] Error fetching latest version:', error);
|
|
versionDisplay = LATEST_RELEASE_NAME;
|
|
}
|
|
} else {
|
|
// Specific version
|
|
versionDisplay = sourceInfo.version;
|
|
}
|
|
}
|
|
|
|
// Clone the existing card
|
|
const card = existingCard.cloneNode(true);
|
|
|
|
// Update data attributes
|
|
card.setAttribute('data-id', 'kefinTweaksPlugin');
|
|
card.setAttribute('data-version', versionDisplay);
|
|
card.setAttribute('data-name', 'KefinTweaks');
|
|
card.setAttribute('data-removable', 'true');
|
|
card.setAttribute('data-status', root ? 'Active' : 'Pending');
|
|
|
|
// Check if this is a Material-UI card (MuiGrid-root)
|
|
const isMuiCard = card.classList.contains('MuiGrid-root');
|
|
|
|
if (isMuiCard) {
|
|
// Handle Material-UI card structure
|
|
const nameElement = card.querySelector('.MuiTypography-root.MuiTypography-body1');
|
|
const versionElement = card.querySelector('.MuiTypography-root.MuiTypography-body2');
|
|
|
|
if (nameElement) {
|
|
nameElement.textContent = 'KefinTweaks';
|
|
}
|
|
|
|
if (versionElement) {
|
|
versionElement.textContent = versionDisplay;
|
|
}
|
|
} else {
|
|
// Handle standard card structure
|
|
const cardTextElements = card.querySelectorAll('.cardText');
|
|
if (cardTextElements.length > 0) {
|
|
// Update first cardText (plugin name with version)
|
|
const nameElement = cardTextElements[0];
|
|
const versionSpan = nameElement.querySelector('.cardText-secondary');
|
|
if (versionSpan) {
|
|
// Update existing version span
|
|
versionSpan.textContent = versionDisplay;
|
|
// Update the name part (text before the span)
|
|
if (nameElement.firstChild && nameElement.firstChild.nodeType === Node.TEXT_NODE) {
|
|
nameElement.firstChild.textContent = 'KefinTweaks';
|
|
} else {
|
|
// Insert name text before the version span
|
|
nameElement.insertBefore(document.createTextNode('KefinTweaks'), versionSpan);
|
|
}
|
|
} else {
|
|
// If no secondary span exists, create one
|
|
nameElement.innerHTML = `KefinTweaks<span class="cardText cardText-secondary">${versionDisplay}</span>`;
|
|
}
|
|
}
|
|
|
|
// Determine status text
|
|
const statusText = root ? 'Status Active' : 'Click to Install';
|
|
|
|
// Update status text (usually last cardText)
|
|
if (cardTextElements.length > 0) {
|
|
cardTextElements[cardTextElements.length - 1].textContent = statusText;
|
|
} else {
|
|
// Add status if it doesn't exist
|
|
const cardFooter = card.querySelector('.cardFooter');
|
|
if (cardFooter) {
|
|
const statusElement = document.createElement('div');
|
|
statusElement.className = 'cardText';
|
|
statusElement.textContent = statusText;
|
|
cardFooter.appendChild(statusElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find and replace the anchor/link with a button that opens the modal
|
|
// For Material-UI cards, the anchor might be structured differently
|
|
let anchorElement = null;
|
|
|
|
if (isMuiCard) {
|
|
// For Material-UI cards, look for anchor in various possible locations
|
|
anchorElement = card.querySelector('a[href]') ||
|
|
card.querySelector('.MuiCardActionArea') ||
|
|
card.querySelector('a');
|
|
} else {
|
|
// For standard cards, look for .cardImageContainer
|
|
anchorElement = card.querySelector('.cardImageContainer');
|
|
}
|
|
|
|
if (anchorElement) {
|
|
// Remove existing link attributes
|
|
anchorElement.removeAttribute('href');
|
|
anchorElement.removeAttribute('data-url');
|
|
anchorElement.removeAttribute('target');
|
|
|
|
// Convert to button if it's not already
|
|
if (anchorElement.tagName.toLowerCase() !== 'button') {
|
|
const button = document.createElement('button');
|
|
// Copy all attributes (except link-specific ones)
|
|
Array.from(anchorElement.attributes).forEach(attr => {
|
|
if (attr.name !== 'href' && attr.name !== 'data-url' && attr.name !== 'target') {
|
|
button.setAttribute(attr.name, attr.value);
|
|
}
|
|
});
|
|
button.className = anchorElement.className;
|
|
|
|
// For Material-UI, preserve Mui classes but add button behavior
|
|
if (isMuiCard) {
|
|
button.setAttribute('type', 'button');
|
|
// Preserve MuiCardActionArea if it exists
|
|
if (anchorElement.classList.contains('MuiCardActionArea')) {
|
|
button.classList.add('MuiCardActionArea');
|
|
}
|
|
} else {
|
|
button.setAttribute('is', 'emby-linkbutton');
|
|
button.style.margin = '0';
|
|
button.style.padding = '0';
|
|
}
|
|
|
|
// Copy all child nodes
|
|
while (anchorElement.firstChild) {
|
|
button.appendChild(anchorElement.firstChild);
|
|
}
|
|
|
|
// Replace the element
|
|
anchorElement.parentNode.replaceChild(button, anchorElement);
|
|
button.onclick = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openKefinTweaksSourceModal();
|
|
};
|
|
} else {
|
|
// It's already a button, just update onclick
|
|
anchorElement.onclick = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openKefinTweaksSourceModal();
|
|
};
|
|
}
|
|
|
|
// Replace any existing img tag with icon div
|
|
let img = card.querySelector('img');
|
|
|
|
if (img) {
|
|
img.src = 'https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/logo.png';
|
|
img.style.width = 'auto';
|
|
img.style.height = '100%';
|
|
img.parentNode.style.background = 'linear-gradient(to bottom, #202020, #101010)';
|
|
} else {
|
|
img = document.createElement('div');
|
|
img.className = 'defaultCardBackground';
|
|
// give the gradient some color
|
|
img.style.background = 'url(https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/logo.png), linear-gradient(to bottom, #202020, #101010)';
|
|
img.style.backgroundSize = 'contain';
|
|
img.style.backgroundPosition = 'center';
|
|
img.style.backgroundRepeat = 'no-repeat';
|
|
img.style.width = '100%';
|
|
img.style.height = '100%';
|
|
img.style.overflow = 'hidden';
|
|
img.style.display = 'flex';
|
|
}
|
|
|
|
let defaultCardBackground = card.querySelector('.defaultCardBackground') ?? card.querySelector('.MuiCardMedia-root');
|
|
|
|
if (img && defaultCardBackground) {
|
|
// Replace the defaultCardBackground with the img
|
|
defaultCardBackground.parentNode.replaceChild(img, defaultCardBackground);
|
|
}
|
|
}
|
|
|
|
// Find and handle menu button (btnCardMenu) if it exists
|
|
const menuButton = card.querySelector('.btnCardMenu');
|
|
if (menuButton) {
|
|
// Remove existing click handlers by cloning
|
|
const clonedMenuButton = menuButton.cloneNode(true);
|
|
menuButton.parentNode.replaceChild(clonedMenuButton, menuButton);
|
|
|
|
// Add click handler to open modal
|
|
clonedMenuButton.onclick = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openKefinTweaksSourceModal();
|
|
};
|
|
}
|
|
|
|
// Append only if still missing (guards against concurrent async completions)
|
|
if (ensureSingleKefinTweaksPluginCard()) {
|
|
return;
|
|
}
|
|
frontEndPlugins.appendChild(card);
|
|
}).catch(err => {
|
|
console.error('[KefinTweaks Installer] Error getting config for card:', err);
|
|
}).finally(() => {
|
|
pluginCardAddInProgress = false;
|
|
});
|
|
}
|
|
|
|
// Try to render the Front End Plugins card when on the plugins page
|
|
async function tryRenderPluginsPage(retryCount = 0) {
|
|
const maxRetries = 50; // ~5s (50 * 100ms)
|
|
if (retryCount === 0) {
|
|
tryRenderToken += 1;
|
|
}
|
|
const token = tryRenderToken;
|
|
|
|
const hash = window.location.hash || '';
|
|
|
|
if (!hash.includes('dashboard/plugins')) {
|
|
return;
|
|
}
|
|
|
|
const pluginsPage = document.querySelector('#pluginsPage:not(.hide)');
|
|
let installedPlugins = pluginsPage ? pluginsPage.querySelector('.installedPlugins') : null;
|
|
|
|
if (!installedPlugins && pluginsPage) {
|
|
// Support for Jellyfin 10.11.X
|
|
installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div');
|
|
}
|
|
|
|
if (!pluginsPage || !installedPlugins) {
|
|
if (retryCount < maxRetries) {
|
|
setTimeout(() => {
|
|
if (token !== tryRenderToken) {
|
|
return;
|
|
}
|
|
tryRenderPluginsPage(retryCount + 1);
|
|
}, 100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
ensureSingleKefinTweaksPluginCard();
|
|
|
|
if (!(await isAdmin())) {
|
|
return;
|
|
}
|
|
|
|
if (token !== tryRenderToken) {
|
|
return;
|
|
}
|
|
|
|
addKefinTweaksPluginCard(installedPlugins);
|
|
}
|
|
|
|
// Hook into Emby.Page.onViewShow
|
|
function setupOnViewShow() {
|
|
if (!(window.Emby && window.Emby.Page)) {
|
|
setTimeout(setupOnViewShow, 100);
|
|
return;
|
|
}
|
|
|
|
if (window.Emby.Page._kefinTweaksOnViewShowHooked) {
|
|
return;
|
|
}
|
|
window.Emby.Page._kefinTweaksOnViewShowHooked = true;
|
|
|
|
const originalOnViewShow = window.Emby.Page.onViewShow;
|
|
|
|
window.Emby.Page.onViewShow = function(...args) {
|
|
if (originalOnViewShow) {
|
|
try {
|
|
originalOnViewShow.apply(this, args);
|
|
} catch (err) {
|
|
console.warn('[KefinTweaks Installer] Error in original onViewShow:', err);
|
|
}
|
|
}
|
|
|
|
tryRenderPluginsPage();
|
|
};
|
|
|
|
console.log('[KefinTweaks Installer] Hooked into Emby.Page.onViewShow');
|
|
|
|
// Cover direct URL / reload when onViewShow already fired before the hook
|
|
tryRenderPluginsPage();
|
|
}
|
|
|
|
function injectPluginCardCss() {
|
|
if (document.getElementById('kefinTweaksPluginCardCss')) {
|
|
return;
|
|
}
|
|
const css = document.createElement('style');
|
|
css.id = 'kefinTweaksPluginCardCss';
|
|
css.textContent = `
|
|
[data-id="kefinTweaksPlugin"] .cardImageContainer::after,
|
|
[data-id="kefinTweaksPlugin"] .MuiButtonBase-root.MuiCardActionArea-root::after {
|
|
content: 'KefinTweaks';
|
|
position: absolute;
|
|
font-size: 2em;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
`;
|
|
document.head.appendChild(css);
|
|
}
|
|
|
|
function shouldInjectCustomPageStyles(config) {
|
|
if (!config) return false;
|
|
const scripts = config.scripts || {};
|
|
const links = config.customMenuLinks;
|
|
return scripts.watchlist === true
|
|
|| scripts.games === true
|
|
|| (Array.isArray(links) && links.length > 0);
|
|
}
|
|
|
|
function injectCustomPageStyles(config) {
|
|
if (!shouldInjectCustomPageStyles(config)) return;
|
|
|
|
const css = `
|
|
#reactRoot.kefin-custom-page-active .skinBody #fallbackPage {
|
|
display: none;
|
|
}
|
|
|
|
#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready]) > * {
|
|
display: none;
|
|
}
|
|
|
|
#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready])::after {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid #f3f3f3;
|
|
border-top: 3px solid #4ecdc4;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
position: relative;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
top: 1em;
|
|
}
|
|
|
|
#fallbackPage[data-kefin-fallback-ready]::after {
|
|
display: none !important;
|
|
content: none !important;
|
|
}
|
|
|
|
#reactRoot.kefin-custom-page-active .backdropImage {
|
|
background: none !important;
|
|
}
|
|
|
|
header.MuiPaper-root + main.MuiBox-root .customPage {
|
|
margin-top: 3rem;
|
|
}
|
|
|
|
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2),
|
|
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img {
|
|
display: none;
|
|
}
|
|
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after {
|
|
content: '\\e88a';
|
|
font-family: 'Material Icons';
|
|
font-size: 1.2rem;
|
|
}
|
|
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child {
|
|
font-size: 0px;
|
|
}
|
|
main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
|
|
padding-top: 1rem !important;
|
|
}
|
|
.layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
|
|
padding-top: 5rem !important;
|
|
}
|
|
`;
|
|
window.KefinTweaksCustomPageStyles = css;
|
|
let style = document.getElementById('kefin-custom-page-styles');
|
|
if (!style) {
|
|
style = document.createElement('style');
|
|
style.id = 'kefin-custom-page-styles';
|
|
(document.head || document.documentElement).appendChild(style);
|
|
}
|
|
style.textContent = css;
|
|
}
|
|
|
|
function escapeVersionLabel(text) {
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function ensureConfigVersionClusterStyles() {
|
|
if (document.getElementById('kefin-config-version-cluster-styles')) return;
|
|
const style = document.createElement('style');
|
|
style.id = 'kefin-config-version-cluster-styles';
|
|
style.textContent = `
|
|
.kefin-config-version-cluster {
|
|
margin-left: auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.6em;
|
|
flex-wrap: wrap;
|
|
}
|
|
.kefin-config-version-label {
|
|
margin: 0;
|
|
opacity: 0.75;
|
|
font-size: 0.95em;
|
|
white-space: nowrap;
|
|
}
|
|
.kefin-config-update-badge {
|
|
display: none;
|
|
padding: 0.35em 0.85em;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(82, 196, 26, 0.55);
|
|
background: rgba(82, 196, 26, 0.18);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.kefin-config-update-badge.is-visible {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.kefin-config-update-badge:hover {
|
|
background: rgba(82, 196, 26, 0.28);
|
|
}
|
|
`;
|
|
(document.head || document.documentElement).appendChild(style);
|
|
}
|
|
|
|
function wireUpdateAvailableBadge(updateBadge) {
|
|
if (!updateBadge || updateBadge.dataset.kefinUpdateWired === 'true') return;
|
|
updateBadge.dataset.kefinUpdateWired = 'true';
|
|
updateBadge.addEventListener('click', async () => {
|
|
try {
|
|
updateBadge.disabled = true;
|
|
await showUpdateAvailablePrompt(window.KefinTweaksConfig, updateBadge.dataset.latestTag || '');
|
|
} catch (error) {
|
|
console.error('[KefinTweaks Installer] Failed to open update prompt:', error);
|
|
} finally {
|
|
updateBadge.disabled = false;
|
|
}
|
|
});
|
|
|
|
(async () => {
|
|
try {
|
|
const result = await isUpdateAvailable(window.KefinTweaksConfig);
|
|
if (!result?.available || !updateBadge.isConnected) return;
|
|
if (result.latestTag) updateBadge.dataset.latestTag = result.latestTag;
|
|
updateBadge.hidden = false;
|
|
updateBadge.classList.add('is-visible');
|
|
} catch (error) {
|
|
console.warn('[KefinTweaks Installer] Update availability check failed:', error);
|
|
}
|
|
})();
|
|
}
|
|
|
|
/**
|
|
* Older configuration.js builds the modal without the version cluster.
|
|
* This plugin script is loaded from @latest, so insert the cluster when it is missing.
|
|
*/
|
|
function injectConfigModalVersionCluster() {
|
|
if (document.getElementById('kefinTweaksInstalledVersion')) return;
|
|
const sourceBtn = document.getElementById('changeKefinTweaksSourceBtn');
|
|
const host = sourceBtn?.parentElement;
|
|
if (!host) return;
|
|
|
|
ensureConfigVersionClusterStyles();
|
|
const label = getInstalledVersionLabel(window.KefinTweaksConfig) || 'Unknown';
|
|
const cluster = document.createElement('div');
|
|
cluster.className = 'kefin-config-version-cluster';
|
|
cluster.dataset.kefinVersionInjected = 'true';
|
|
cluster.innerHTML = `
|
|
<span id="kefinTweaksInstalledVersion" class="listItemBodyText secondary kefin-config-version-label" title="Installed KefinTweaks version">${escapeVersionLabel(label)}</span>
|
|
<button type="button" id="kefinTweaksUpdateAvailableBadge" class="kefin-config-update-badge" title="A newer KefinTweaks release is available" aria-label="Update available" hidden>
|
|
Update Available!
|
|
</button>
|
|
`;
|
|
host.appendChild(cluster);
|
|
wireUpdateAvailableBadge(cluster.querySelector('#kefinTweaksUpdateAvailableBadge'));
|
|
}
|
|
|
|
function watchConfigModalForVersionCluster() {
|
|
injectConfigModalVersionCluster();
|
|
const observer = new MutationObserver(() => injectConfigModalVersionCluster());
|
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
}
|
|
|
|
// Initialize the installer (no login/admin gate — admin is checked when rendering the card)
|
|
function initialize() {
|
|
checkAndLoadInjector();
|
|
injectPluginCardCss();
|
|
setupOnViewShow();
|
|
watchConfigModalForVersionCluster();
|
|
}
|
|
|
|
// Resolve @latest, @main, or @experimental to actual version/commit hash (same as injector.js)
|
|
async function resolveRootVersion(root) {
|
|
const latestMatch = root.match(/@latest(\/|$)/);
|
|
const mainMatch = root.match(/@main(\/|$)/);
|
|
const experimentalMatch = root.match(/@experimental(\/|$)/);
|
|
|
|
if (latestMatch) {
|
|
try {
|
|
const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data && data.tag_name) {
|
|
const versionTag = data.tag_name.startsWith('v') ? data.tag_name : 'v' + data.tag_name;
|
|
return root.replace('@latest', `@${versionTag}`);
|
|
}
|
|
console.warn('[KefinTweaks Installer] Could not fetch latest version, using @latest');
|
|
return root;
|
|
}
|
|
console.warn('[KefinTweaks Installer] Failed to fetch latest version, using @latest');
|
|
return root;
|
|
} catch (error) {
|
|
console.warn('[KefinTweaks Installer] Error fetching latest version:', error);
|
|
return root;
|
|
}
|
|
}
|
|
|
|
if (mainMatch) {
|
|
try {
|
|
const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/main');
|
|
if (response.ok) {
|
|
const commit = await response.json();
|
|
if (commit && commit.sha) {
|
|
return root.replace('@main', `@${commit.sha}`);
|
|
}
|
|
console.warn('[KefinTweaks Installer] Could not fetch commit hash, using @main');
|
|
return root;
|
|
}
|
|
console.warn('[KefinTweaks Installer] Failed to fetch commit hash, using @main');
|
|
return root;
|
|
} catch (error) {
|
|
console.warn('[KefinTweaks Installer] Error fetching commit hash:', error);
|
|
return root;
|
|
}
|
|
}
|
|
|
|
if (experimentalMatch) {
|
|
try {
|
|
const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/experimental');
|
|
if (response.ok) {
|
|
const commit = await response.json();
|
|
if (commit && commit.sha) {
|
|
return root.replace('@experimental', `@${commit.sha}`);
|
|
}
|
|
console.warn('[KefinTweaks Installer] Could not fetch experimental commit hash, using @experimental');
|
|
return root;
|
|
}
|
|
console.warn('[KefinTweaks Installer] Failed to fetch experimental commit hash, using @experimental');
|
|
return root;
|
|
} catch (error) {
|
|
console.warn('[KefinTweaks Installer] Error fetching experimental commit hash:', error);
|
|
return root;
|
|
}
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
// Load injector.js from a specific root URL (fallback when bake is absent).
|
|
// GitHub floating resolve + persist only for admins; non-admins use pinned/symbolic as-is.
|
|
async function loadInjectorFromRoot(root) {
|
|
if (!root || root === '') {
|
|
console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty');
|
|
return false;
|
|
}
|
|
|
|
const rootUrl = normalizeKefinRootUrl(root);
|
|
const admin = await isAdmin();
|
|
let resolvedRoot = rootUrl;
|
|
|
|
if (isFloatingKefinRoot(rootUrl)) {
|
|
if (admin) {
|
|
resolvedRoot = normalizeKefinRootUrl(await resolveRootVersion(rootUrl));
|
|
} else {
|
|
const pinned = normalizeKefinRootUrl(window.KefinTweaksConfig?.kefinTweaksRootResolved || '');
|
|
resolvedRoot = pinned || rootUrl;
|
|
console.log('[KefinTweaks Installer] Non-admin: skipping GitHub resolve; using', resolvedRoot);
|
|
}
|
|
}
|
|
|
|
if (resolvedRoot !== rootUrl) {
|
|
console.log('[KefinTweaks Installer] Resolved root from', rootUrl, 'to', resolvedRoot);
|
|
}
|
|
|
|
// Admin: persist tip move; if replacing a previous pin, countdown reload (no tip injector this session)
|
|
if (admin && isFloatingKefinRoot(rootUrl)) {
|
|
try {
|
|
const expected = normalizeKefinRootUrl(resolvedRoot);
|
|
if (!window.KefinTweaksConfig) {
|
|
window.KefinTweaksConfig = {};
|
|
}
|
|
const symbolicRoot = window.KefinTweaksConfig.kefinTweaksRoot || rootUrl;
|
|
const current = normalizeKefinRootUrl(window.KefinTweaksConfig.kefinTweaksRootResolved || '');
|
|
if (expected && expected !== current) {
|
|
window.KefinTweaksConfig.kefinTweaksRootResolved = expected;
|
|
console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
|
|
if (isLoggedInForInjectorWrite()) {
|
|
const persisted = await saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
|
|
if (persisted && current) {
|
|
// Prior pin existed — force reload so scripts/Config stay aligned
|
|
maybeQueueResolvedRootUpdateToast({
|
|
symbolicRoot,
|
|
previousResolved: current,
|
|
nextResolved: expected,
|
|
persisted: true
|
|
});
|
|
return true;
|
|
}
|
|
if (persisted && !current) {
|
|
// First pin: continue and load injector from the new resolved URL
|
|
console.log('[KefinTweaks Installer] First resolved pin persisted; loading injector.js');
|
|
}
|
|
} else {
|
|
console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e);
|
|
}
|
|
}
|
|
|
|
if (window.__kefinTipReloadScheduled) {
|
|
console.log('[KefinTweaks Installer] Tip reload scheduled; skipping injector.js load');
|
|
return true;
|
|
}
|
|
|
|
const injectorUrl = `${resolvedRoot}injector.js`;
|
|
|
|
// Check if injector is already loaded from this URL
|
|
const existingScript = document.querySelector(`script[src="${injectorUrl}"]`);
|
|
if (existingScript) {
|
|
console.log('[KefinTweaks Installer] Injector already loaded from:', injectorUrl);
|
|
return true;
|
|
}
|
|
|
|
console.log('[KefinTweaks Installer] Loading injector.js from:', injectorUrl);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = injectorUrl;
|
|
script.async = true;
|
|
|
|
script.onload = () => {
|
|
console.log('[KefinTweaks Installer] Injector loaded successfully');
|
|
resolve(true);
|
|
};
|
|
|
|
script.onerror = (error) => {
|
|
console.error('[KefinTweaks Installer] Failed to load injector:', error);
|
|
reject(new Error(`Failed to load injector.js from ${injectorUrl}`));
|
|
};
|
|
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
// Check if config exists; auto-create bake when missing (admin); never dual-inject when bake attached
|
|
async function checkAndLoadInjector() {
|
|
try {
|
|
// Prefer in-page Config (already injected). Avoid /Plugins GET on normal page load for non-admins.
|
|
let config = window.KefinTweaksConfig || null;
|
|
if (!config) {
|
|
const admin = await isAdmin();
|
|
if (admin) {
|
|
await waitForLoginForInjectorWrite();
|
|
config = await getKefinTweaksConfig();
|
|
}
|
|
}
|
|
|
|
// Before injector/utils: hide fallback page chrome for custom routes
|
|
injectCustomPageStyles(config);
|
|
|
|
const root = config?.kefinTweaksRoot || '';
|
|
if (!root || root === '') {
|
|
console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.');
|
|
return;
|
|
}
|
|
|
|
// Bake already on page: prefer tip sync / heal; only dual-load when bake is inert
|
|
let attachedBakeInertFallthrough = false;
|
|
if (isKefinInjectorAttachedOnPage()) {
|
|
const admin = await isAdmin();
|
|
if (admin) {
|
|
console.log('[KefinTweaks Installer] KefinTweaks-injector attached; admin tip sync without live inject');
|
|
await waitForLoginForInjectorWrite();
|
|
const heal = await healBrickedOfficialCdnBake(config);
|
|
if (heal.healed || window.__kefinTipReloadScheduled) {
|
|
// Latest release check must run here — injector.js is not loaded on this path
|
|
await runPinnedVersionUpdateCheck(config);
|
|
return;
|
|
}
|
|
await handleAdminAttachedBakeTipSync(config);
|
|
await runPinnedVersionUpdateCheck(config);
|
|
if (window.KefinTweaksScriptsPreloaded || window.__kefinTipReloadScheduled) {
|
|
return;
|
|
}
|
|
console.warn(
|
|
'[KefinTweaks Installer] Bake attached but scripts not preloaded; falling through to live injector.js'
|
|
);
|
|
attachedBakeInertFallthrough = true;
|
|
} else if (window.KefinTweaksScriptsPreloaded) {
|
|
console.log('[KefinTweaks Installer] KefinTweaks-injector attached; non-admin skipping live injector.js');
|
|
try {
|
|
await fillMissingBakedAssets(config);
|
|
} catch (e) {
|
|
console.warn('[KefinTweaks Installer] Non-admin bake gap fill failed:', e);
|
|
}
|
|
return;
|
|
} else {
|
|
console.warn(
|
|
'[KefinTweaks Installer] Bake attached but scripts not preloaded; non-admin falling through to live injector.js'
|
|
);
|
|
attachedBakeInertFallthrough = true;
|
|
}
|
|
}
|
|
|
|
// Inert attached bake: skip ensure (would rebuild from possibly-bad user root) — live inject only
|
|
if (attachedBakeInertFallthrough) {
|
|
await loadInjectorFromRoot(root);
|
|
return;
|
|
}
|
|
|
|
// Releases before v0.5.0 have no kefinTweaks-loader.js. Load that tag's injector.js directly.
|
|
if (rootPredatesKefinLoader(root)) {
|
|
console.log('[KefinTweaks Installer] Pin predates kefinTweaks-loader.js; loading injector.js');
|
|
await loadInjectorFromRoot(root);
|
|
await runPinnedVersionUpdateCheck(config);
|
|
return;
|
|
}
|
|
|
|
if (await isAdmin()) {
|
|
await waitForLoginForInjectorWrite();
|
|
const ensured = await ensureKefinTweaksInjectorEntry(config);
|
|
if (ensured.ok && ensured.plan) {
|
|
window.KefinTweaksScriptsPreloaded = true;
|
|
window.KefinTweaksInjectorAttached = true;
|
|
if (window.KefinTweaksLoader) {
|
|
window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS);
|
|
window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets);
|
|
}
|
|
try {
|
|
document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', {
|
|
detail: {
|
|
stamp: ensured.plan.stamp,
|
|
timestamp: new Date().toISOString(),
|
|
autoCreated: true
|
|
}
|
|
}));
|
|
} catch (e) { /* ignore */ }
|
|
console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create');
|
|
await runPinnedVersionUpdateCheck(config);
|
|
// Tip advance inside ensure may have scheduled reload; never also load tip injector.js
|
|
if (window.__kefinTipReloadScheduled) return;
|
|
return;
|
|
}
|
|
console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
|
|
await runPinnedVersionUpdateCheck(config);
|
|
} else {
|
|
console.log('[KefinTweaks Installer] KefinTweaks-injector not on page (non-admin); loading injector.js');
|
|
}
|
|
|
|
await loadInjectorFromRoot(root);
|
|
} catch (error) {
|
|
console.error('[KefinTweaks Installer] Error checking config:', error);
|
|
}
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initialize();
|
|
});
|
|
} else {
|
|
initialize();
|
|
}
|
|
|
|
console.log('[KefinTweaks Installer] Installer script loaded');
|
|
})();
|
|
|