import localforage from "localforage"; import browser from "webextension-polyfill"; import { type CustomTheme, getForcedDarkMode, type LoadedCustomTheme, shouldForceThemeAppearance, } from "@/types/CustomThemes"; import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { getApiBase } from "@/seqta/utils/DevApiBase"; import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { clearCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables, } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; import { clearThemeRuntime, injectThemeDom, runThemeScript, type ThemeDomSpec, type ThemeScriptSpec, validateThemeDom, validateThemeScript, } from "./theme-runtime"; type ThemeContent = { id: string; name: string; coverImage?: string; // base64, optional description: string; defaultColour?: string; CanChangeColour?: boolean; CustomCSS?: string; hideThemeName?: boolean; forceTheme?: boolean; forceDark?: boolean; adaptiveCssVariables?: string[]; images?: { id: string; variableName: string; data: string }[]; // data: base64 themeScript?: ThemeScriptSpec; themeDom?: ThemeDomSpec; }; export type InstallThemeMeta = { fromStore: boolean; /** Server list `updated_at` (Unix seconds); set when installing from store. */ serverUpdatedAtSec?: number; forceTheme?: boolean; adaptiveCssVariables?: string[]; images?: { id: string; variableName: string; data: string }[]; // data: base64 }; export class ThemeManager { private static instance: ThemeManager; private currentTheme: CustomTheme | null = null; private styleElement: HTMLStyleElement | null = null; private previewStyleElement: HTMLStyleElement | null = null; private previousImageVariableNames: string[] = []; private originalPreviewColor: string | null = null; private originalPreviewTheme: boolean | null = null; private imageUrlCache: Map = new Map(); private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private storeUpdateCheckRunning = false; private headObserver: MutationObserver | null = null; private constructor() { console.debug("[ThemeManager] Initializing..."); } public static getInstance(): ThemeManager { if (!ThemeManager.instance) { ThemeManager.instance = new ThemeManager(); } return ThemeManager.instance; } /** * Get the currently active theme */ public getCurrentTheme(): CustomTheme | null { return this.currentTheme; } /** * Get a theme by ID from storage */ public async getTheme(themeId: string): Promise { console.debug("[ThemeManager] Getting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; return theme; } catch (error) { console.error("[ThemeManager] Error getting theme:", error); return null; } } /** * Get the ID of the currently selected theme */ public getSelectedThemeId(): string { return settingsState.selectedTheme; } /** * Update the last transition point based on a click or event */ public setTransitionPoint(x: number, y: number): void { this.lastTransitionPoint = { x, y }; } /** * Apply a view transition animation */ private async applyViewTransition(callback: () => void): Promise { if ( !document.startViewTransition || !settingsState.animations || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) { // Just run the callback without animation if transitions not supported callback(); return; } // Use last known transition point or fallback to center const x = this.lastTransitionPoint.x || window.innerWidth / 2; const y = this.lastTransitionPoint.y || window.innerHeight / 2; const right = window.innerWidth - x; const bottom = window.innerHeight - y; const maxRadius = Math.hypot( Math.max(x, right), Math.max(y, bottom), ); await document.startViewTransition(() => { callback(); }).ready; try { document.documentElement.animate( { clipPath: [ `circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`, ], }, { duration: 400, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)', } ); } catch (error) { console.error("[ThemeManager] View Transition animation error:", error); } } /** * Disable the current theme without deleting it */ public async disableTheme(): Promise { console.debug("[ThemeManager] Disabling current theme"); try { if (!this.currentTheme) { console.debug("[ThemeManager] No theme to disable"); return; } await this.removeTheme(this.currentTheme); this.currentTheme = null; settingsState.selectedTheme = ""; console.debug("[ThemeManager] Theme disabled successfully"); } catch (error) { console.error("[ThemeManager] Error disabling theme:", error); } } /** * After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW). * Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme(). * The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a * flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer. */ public async prepareThemeAfterCloudSync(): Promise { try { const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY); const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]; if (pending === undefined) return; await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY); if (typeof pending !== "string") return; const id = pending.trim(); if (!id) return; const existing = (await localforage.getItem(id)) as CustomTheme | null; if (existing) return; await this.downloadAndInstallStoreTheme({ id, name: id }); } catch (e) { console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e); } } /** * Initialize the theme system and restore previous state */ public async initialize(): Promise { console.debug("[ThemeManager] Starting initialization"); try { const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad"; const migrationCSS = "#title {\nbackground: transparent !important;\n}"; const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null; if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) { theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS; await localforage.setItem(neumorphicThemeId, theme); } const themeCreatorOpen = localStorage.getItem("themeCreatorOpen"); if (themeCreatorOpen === "true") { console.debug( "[ThemeManager] Theme creator was open, clearing preview state", ); this.clearPreview(); localStorage.removeItem("themeCreatorOpen"); } if (settingsState.selectedTheme) { console.debug( "[ThemeManager] Found selected theme, restoring:", settingsState.selectedTheme, ); await this.setTheme(settingsState.selectedTheme, false); } } catch (error) { console.error("[ThemeManager] Error during initialization:", error); } finally { void this.checkStoreThemeUpdates(); } } /** * Clean up theme system resources */ public async cleanup(): Promise { console.debug("[ThemeManager] Cleaning up resources"); try { if (this.currentTheme) { await this.removeTheme(this.currentTheme, false); } } catch (error) { console.error("[ThemeManager] Error during cleanup:", error); } } /** * Set and apply a theme by ID */ public async setTheme(themeId: string, applyViewTransition: boolean = true): Promise { console.debug("[ThemeManager] Setting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; if (!theme) { console.error("[ThemeManager] Theme not found:", themeId); return; } // Store original settings before applying new theme if (!settingsState.selectedTheme) { console.debug("[ThemeManager] Storing original settings"); settingsState.originalSelectedColor = settingsState.selectedColor; if (shouldForceThemeAppearance(theme)) { settingsState.originalDarkMode = settingsState.DarkMode; } } // Use view transition for the theme change if (applyViewTransition) { await this.applyViewTransition(async () => { // Remove current theme if exists if (this.currentTheme) { console.debug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } // Apply new theme await this.applyTheme(theme); this.currentTheme = theme; settingsState.selectedTheme = themeId; }); } else { // Remove current theme if exists if (this.currentTheme) { console.debug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } // Apply new theme await this.applyTheme(theme); this.currentTheme = theme; settingsState.selectedTheme = themeId; } void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error setting theme:", error); } } /** * Apply theme components (CSS, images, settings) */ private async applyTheme(theme: CustomTheme): Promise { console.debug("[ThemeManager] Applying theme:", theme.name); try { // Run the theme script BEFORE injecting CustomCSS so any state the // script publishes (e.g. `data-city-state` and `--city-sky-color` for // Noir City) is already on when the new CSS rules paint. // Otherwise the CSS lands with var() unresolved and the page flashes // its previous state before snapping to the right colour. runThemeScript(theme.themeScript); // Apply custom CSS if (theme.CustomCSS) { console.debug("[ThemeManager] Applying custom CSS"); this.applyCustomCSS(theme.CustomCSS); } // Apply custom images if (theme.CustomImages) { console.debug("[ThemeManager] Applying custom images"); theme.CustomImages.forEach((image) => { const imageUrl = URL.createObjectURL(image.blob); document.documentElement.style.setProperty( "--" + image.variableName, `url(${imageUrl})`, ); }); } // Apply theme settings if (shouldForceThemeAppearance(theme)) { const dark = getForcedDarkMode(theme); console.debug("[ThemeManager] Setting dark mode:", dark); settingsState.DarkMode = dark; } // Use the stored selected color if available, otherwise use the default if (theme.selectedColor) { console.debug( "[ThemeManager] Restoring saved color:", theme.selectedColor, ); settingsState.selectedColor = theme.selectedColor; } else if (theme.defaultColour) { console.debug( "[ThemeManager] Using default color:", theme.defaultColour, ); settingsState.selectedColor = theme.defaultColour; } setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); injectThemeDom(theme.themeDom); } catch (error) { console.error("[ThemeManager] Error applying theme:", error); } } /** * Remove theme and restore original settings with view transition */ private async removeTheme( theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { console.debug("[ThemeManager] Removing theme with transition:", theme.name); try { await this.applyViewTransition(async () => { await this.removeThemeWithoutTransition(theme, clearSelectedTheme); }); } catch (error) { console.error("[ThemeManager] Error removing theme with transition:", error); } } /** * Remove theme without applying view transition animation */ private async removeThemeWithoutTransition( theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { console.debug("[ThemeManager] Removing theme:", theme.name); try { clearThemeRuntime(); // Disconnect the head observer BEFORE removing the style element, // otherwise the removal fires the observer and it would no-op only // because the style is already gone — wasted work, but harmless. this.disconnectStyleObserver(); // Remove custom CSS if (this.styleElement) { console.debug("[ThemeManager] Removing custom CSS"); this.styleElement.remove(); this.styleElement = null; } // Remove custom images if (theme.CustomImages) { console.debug("[ThemeManager] Removing custom images"); theme.CustomImages.forEach((image) => { const value = document.documentElement.style.getPropertyValue( "--" + image.variableName, ); if (value) { URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper } document.documentElement.style.removeProperty( "--" + image.variableName, ); }); } if (this.currentTheme) { // Store the current color with the theme before removing it const selectedColor = settingsState.selectedColor; const markUserEditedForColor = this.currentTheme.userEdited !== true && this.currentTheme.installedFromStore !== false && selectedColor && this.currentTheme.defaultColour && selectedColor.trim().toLowerCase() !== this.currentTheme.defaultColour.trim().toLowerCase(); await localforage.setItem(this.currentTheme.id, { ...this.currentTheme, selectedColor, ...(markUserEditedForColor ? { userEdited: true } : {}), }); } // Restore original settings if (settingsState.originalSelectedColor) { console.debug( "[ThemeManager] Restoring original color:", settingsState.originalSelectedColor, ); settingsState.selectedColor = settingsState.originalSelectedColor; } if (settingsState.originalDarkMode !== undefined) { console.debug( "[ThemeManager] Restoring original dark mode:", settingsState.originalDarkMode, ); settingsState.DarkMode = settingsState.originalDarkMode; delete settingsState.originalDarkMode; } this.currentTheme = null; if (clearSelectedTheme) { settingsState.selectedTheme = ""; } clearCustomThemeAdaptiveCssVariables(); } catch (error) { console.error("[ThemeManager] Error removing theme:", error); } } /** * Apply custom CSS to the document. The `