diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js
index b7d66e7..9b74582 100644
--- a/kefinTweaks-plugin.js
+++ b/kefinTweaks-plugin.js
@@ -298,6 +298,9 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
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);
@@ -632,6 +635,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
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;
@@ -751,13 +766,156 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return `${JSDELIVR_BASE}@${tag}/`;
}
- async function showPinnedVersionUpdateToast(config, latestTag) {
- const toastFn = await waitForGlobal(() => window.KefinTweaksToaster?.toast, 20000);
- if (!toastFn) {
- console.warn('[KefinTweaks Installer] Toaster unavailable; skipping update toast');
- return;
+ 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);
@@ -777,60 +935,55 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
window.open(releaseNotesUrl, '_blank', 'noopener');
} catch (_) { /* ignore */ }
};
-
- const updateMessage = `KefinTweaks ${versionLabel} is available!`;
- const handle = toastFn(
- updateMessage,
- '0',
- false,
- {
- link: {
- label: updateMessage,
- 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?.();
- toastFn(
- 'KefinTweaks updated. Please refresh the page for the changes to take effect.',
- '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();
+ 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?.();
- } catch (e) {
- console.error('[KefinTweaks Installer] Dismiss action failed:', e);
+ 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) {
@@ -2695,11 +2848,120 @@ main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-cus
style.textContent = css;
}
+ function escapeVersionLabel(text) {
+ return String(text)
+ .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 = `
+ ${escapeVersionLabel(label)}
+
+ `;
+ 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)
@@ -2928,6 +3190,14 @@ main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-cus
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);