diff --git a/scripts/build.mjs b/scripts/build.mjs index e1ba51e..9491f2c 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -74,7 +74,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.1.0-1"`, + version: `"1.1.0-2"`, }, }, }, diff --git a/src/lib/addons/themes/colors/fallbacks.ts b/src/lib/addons/themes/colors/fallbacks.ts new file mode 100644 index 0000000..b640a17 --- /dev/null +++ b/src/lib/addons/themes/colors/fallbacks.ts @@ -0,0 +1,220 @@ +/** + * Fallback lists and maps for the theme updater. + * + * These are the centralized, exported constants and small helpers used to + * decide which semantic/raw keys to ensure exist at runtime so components + * that read legacy or nonstandard token names receive sensible values. + * + * The goal is to keep all "magic" name lists in one place to make maintenance + * easier and to support the idea of pulling a single file into the updater. + * + * This file intentionally contains only data and tiny pure helpers (regexes). + * Any logic that requires runtime tables (e.g. reading tokenRef.RawColor) + * should remain in the updater and consume these lists. + */ + +/* eslint-disable @typescript-eslint/ban-types */ + +export const ensureRawKeys: string[] = [ + // Autocomplete / quickswitcher legacy/raw keys + "AUTOCOMPLETE_BACKGROUND", + "AUTOCOMPLETE_BG", + "AUTOCOMPLETE_TEXT", + "DEPRECATED_QUICKSWITCHER_INPUT_BACKGROUND", + "QUICKSWITCHER_INPUT_BACKGROUND", + "deprecated-quickswitcher-input-background", + "autocomplete-bg", + // Emoji picker / popover keys (various clients use different names) + "EMOJI_PICKER_BACKGROUND", + "EMOJI_POPOVER_BACKGROUND", + "EMOJI_POPUP_BACKGROUND", + "EMOJI_PANEL_BG", + "EMOJI_PICKER_PANEL_BG", + "EMOJI_PICKER_FOOTER_BG", + "EMOJI_PICKER_FOOTER_BACKGROUND", +]; + +export const ensureSemanticKeys: string[] = [ + "AUTOCOMPLETE_BACKGROUND", + "AUTOCOMPLETE_TEXT", + "EMOJI_PICKER_BACKGROUND", + "EMOJI_POPOVER_BACKGROUND", + "EMOJI_PANEL_BG", + "EMOJI_PICKER_PANEL_BG", +]; + +/** + * Mirror groups - these group related aliases together so the updater can + * pick the best available candidate among them and mirror the value across + * all aliases (both raw and semantic tables). + */ +export const mirrorGroups: Record = { + // Autocomplete aliases (cover many legacy and client variants) + AUTOCOMPLETE: [ + "AUTOCOMPLETE_BACKGROUND", + "AUTOCOMPLETE_BG", + "autocomplete-bg", + "AUTOCOMPLETE-BG", + "QUICKSWITCHER_INPUT_BACKGROUND", + "DEPRECATED_QUICKSWITCHER_INPUT_BACKGROUND", + "deprecated-quickswitcher-input-background", + "QUICKSWITCHER_INPUT_BG", + "DEPRECATED_QUICKSWITCHER_INPUT_BG", + "deprecated-quickswitcher-input-bg", + "DEPRECATED_QUICKSWITCHER_INPUT_BACKGROUND", + ], + // Emoji / expression picker aliases, include footer variants explicitly + EMOJI_PICKER: [ + "EMOJI_PICKER_BACKGROUND", + "EMOJI_POPOVER_BACKGROUND", + "EMOJI_POPUP_BACKGROUND", + "EMOJI_PANEL_BG", + "EMOJI_PICKER_PANEL_BG", + "EMOJI_PICKER_FOOTER_BG", + "EMOJI_PICKER_FOOTER_BACKGROUND", + "EXPRESSION_PICKER_BG", + "EXPRESSION_PICKER_BACKGROUND", + "expression-picker-bg", + ], + // Emoji text / label color aliases + EMOJI_TEXT: [ + "EMOJI_TEXT", + "EMOJI_PICKER_TEXT", + "EMOJI_POPOVER_TEXT", + "EMOJI_TOOLTIP_TEXT", + ], +}; + +/** + * A small explicit list of additional semantics that often are missing from themes + * and frequently cause visible light fallbacks (embeds, command/autocomplete bars, replies). + */ +export const extraSemanticNames: string[] = [ + "TEXT_EMBED_TITLE", + "TEXT_EMBED_AUTHOR", + "TEXT_EMBED_CONTENT", + "TEXT_EMBED_FOOTER", + "TEXT_REPLY", + "TEXT_REPLY_BYLINE", + "INPUT_PRIMARY", + "INPUT_SECONDARY", + "AUTOCOMPLETE_BACKGROUND", + "AUTOCOMPLETE_TEXT", +]; + +/** + * Common semantic fallback mapping for names that have different canonical forms + * across various theme manifests or engine versions. + * + * When a semantic key is missing, the updater will try the mapped key as a fallback. + */ +export const SEMANTIC_FALLBACK_MAP: Record = { + BG_BACKDROP: "BACKGROUND_FLOATING", + BG_BASE_PRIMARY: "BACKGROUND_PRIMARY", + BG_BASE_SECONDARY: "BACKGROUND_SECONDARY", + BG_BASE_TERTIARY: "BACKGROUND_SECONDARY_ALT", + BG_MOD_FAINT: "BACKGROUND_MODIFIER_ACCENT", + BG_MOD_STRONG: "BACKGROUND_MODIFIER_ACCENT", + BG_MOD_SUBTLE: "BACKGROUND_MODIFIER_ACCENT", + BG_SURFACE_OVERLAY: "BACKGROUND_FLOATING", + BG_SURFACE_OVERLAY_TMP: "BACKGROUND_FLOATING", + BG_SURFACE_RAISED: "BACKGROUND_MOBILE_PRIMARY", +}; + +/** + * Candidate lists used by the updater's specialized cases (autocomplete/text). + * These lists are intentionally conservative and ordered by preference. + */ +export const AUTOCOMPLETE_FALLBACK_CANDIDATES: string[] = [ + // avatar/bot bar variants (mirrors "bot avatar bar" style) + "AVATAR_BAR_BG", + "BOT_AVATAR_BG", + "AVATAR_BAR_BACKGROUND", + // you-bar / personal bar variants + "YOU_BAR_BG", + "YOU_BAR_BACKGROUND", + "YOU_BAR", + // quickswitcher / autocomplete legacy variants + "QUICKSWITCHER_INPUT_BACKGROUND", + "DEPRECATED_QUICKSWITCHER_INPUT_BACKGROUND", + "AUTOCOMPLETE_BG", + // chat/input fallbacks + "CHAT_INPUT_BACKGROUND", + "CHANNELTEXTAREA_BACKGROUND", + // general surface/background fallbacks + "BACKGROUND_FLOATING", + "BACKGROUND_PRIMARY", + "CARD_PRIMARY_BG", +]; + +export const TEXT_CANDIDATES: string[] = [ + "TEXT_NORMAL", + "TEXT_PRIMARY", + "TEXT_SECONDARY", + "TEXT_PRIMARY_ON_BACKGROUND", + "TEXT_MUTED", +]; + +/** + * Delays (in ms) that the updater will use when scheduling reapply attempts. + * Exported so tests / orchestration can inspect or reuse these values. + */ +export const REAPPLY_DELAYS: number[] = [300, 700, 1500]; + +/** + * Regex used to detect footer / popup / panel-like aliases where a lighter surface + * fallback (e.g. BACKGROUND_FLOATING) is preferred instead of a pure raw candidate. + */ +export const FOOTER_KEY_REGEX = /FOOTER|footer|PANEL|panel|POPUP/i; + +/** + * Utility: choose a "softened" fallback color when no explicit mapping exists. + * NOTE: This helper intentionally does not access runtime token tables - it only + * performs very small, deterministic transformations. The updater should call + * this with an already-determined candidate color string. + * + * Keep this simple to avoid importing heavy color libs here; the updater owns any + * chroma/contrast logic that depends on runtime state. + */ +export function softenHardFallback(fallbackHex: string, preferHex = true): string { + // If caller provides something falsy, return a safe default. + if (!fallbackHex || typeof fallbackHex !== "string") return "#000000"; + + // Basic normalization: ensure starts with '#', otherwise return as-is. + if (!fallbackHex.startsWith("#")) return fallbackHex; + + // Very small, naive "soften" algorithm: when hex appears very dark (#000...), + // nudge it to a slightly lighter shade. This is intentionally minimal to avoid + // reintroducing heavy dependencies here. + const normalized = fallbackHex.slice(1); + if (normalized.length !== 3 && normalized.length !== 6) return fallbackHex; + + // Expand short hex (#abc -> #aabbcc) + const expanded = + normalized.length === 3 + ? normalized.split("").map((c) => c + c).join("") + : normalized; + + try { + const r = parseInt(expanded.slice(0, 2), 16); + const g = parseInt(expanded.slice(2, 4), 16); + const b = parseInt(expanded.slice(4, 6), 16); + + // If extremely dark, lighten by a fixed delta; otherwise return original. + const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + if (lum < 0.05) { + const delta = 30; // lighten by ~12% + const nr = Math.min(255, r + delta); + const ng = Math.min(255, g + delta); + const nb = Math.min(255, b + delta); + const hex = `#${nr.toString(16).padStart(2, "0")}${ng + .toString(16) + .padStart(2, "0")}${nb.toString(16).padStart(2, "0")}`; + return preferHex ? hex : hex; + } + + return `#${expanded}`; + } catch { + return fallbackHex; + } +} diff --git a/src/lib/addons/themes/colors/patches/resolver.ts b/src/lib/addons/themes/colors/patches/resolver.ts index 103f5ce..9e4f0af 100644 --- a/src/lib/addons/themes/colors/patches/resolver.ts +++ b/src/lib/addons/themes/colors/patches/resolver.ts @@ -10,80 +10,164 @@ const tokenReference = findByProps("SemanticColor"); const isThemeModule = createLazyModule(byMutableProp("isThemeDark")); const SEMANTIC_FALLBACK_MAP: Record = { - "BG_BACKDROP": "BACKGROUND_FLOATING", - "BG_BASE_PRIMARY": "BACKGROUND_PRIMARY", - "BG_BASE_SECONDARY": "BACKGROUND_SECONDARY", - "BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT", - "BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT", - "BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT", - "BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT", - "BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING", - "BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING", - "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY" + BG_BACKDROP: "BACKGROUND_FLOATING", + BG_BASE_PRIMARY: "BACKGROUND_PRIMARY", + BG_BASE_SECONDARY: "BACKGROUND_SECONDARY", + BG_BASE_TERTIARY: "BACKGROUND_SECONDARY_ALT", + BG_MOD_FAINT: "BACKGROUND_MODIFIER_ACCENT", + BG_MOD_STRONG: "BACKGROUND_MODIFIER_ACCENT", + BG_MOD_SUBTLE: "BACKGROUND_MODIFIER_ACCENT", + BG_SURFACE_OVERLAY: "BACKGROUND_FLOATING", + BG_SURFACE_OVERLAY_TMP: "BACKGROUND_FLOATING", + BG_SURFACE_RAISED: "BACKGROUND_MOBILE_PRIMARY", }; export default function patchDefinitionAndResolver() { - const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0; + const callback = ([theme]: any[]) => + theme === _colorRef.key ? [_colorRef.current!.reference] : void 0; - Object.keys(tokenReference.RawColor).forEach(key => { - Object.defineProperty(tokenReference.RawColor, key, { - configurable: true, - enumerable: true, - get: () => { - const ret = _colorRef.current?.raw[key]; - return ret || _colorRef.origRaw[key]; - } - }); + Object.keys(tokenReference.RawColor).forEach((key) => { + Object.defineProperty(tokenReference.RawColor, key, { + configurable: true, + enumerable: true, + get: () => { + const ret = _colorRef.current?.raw[key]; + return ret || _colorRef.origRaw[key]; + }, }); + }); - const unpatches = [ - before("isThemeDark", isThemeModule, callback), - before("isThemeLight", isThemeModule, callback), - before("updateTheme", NativeThemeModule, callback), - instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => { - if (!_colorRef.current) return orig(...args); - if (args[0] !== _colorRef.key) return orig(...args); - - args[0] = _colorRef.current.reference; - - const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]); - - let semanticDef = _colorRef.current.semantic[name]; - if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) { - semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]]; - } - - if (semanticDef?.value) { - if (semanticDef.opacity === 1) return semanticDef.value; - return chroma(semanticDef.value).alpha(semanticDef.opacity).hex(); - } - - const rawValue = _colorRef.current.raw[colorDef.raw]; - if (rawValue) { - // Set opacity if needed - return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex(); - } - - // Fallback to default - return orig(...args); - }), - () => { - // Not the actual module but.. yeah. - Object.defineProperty(tokenReference, "RawColor", { - configurable: true, - writable: true, - value: _colorRef.origRaw - }); + const unpatches = [ + before("isThemeDark", isThemeModule, callback), + before("isThemeLight", isThemeModule, callback), + before("updateTheme", NativeThemeModule, callback), + // Prefer the active bn-theme for all resolveSemanticColor calls. + // Add debug logs to help trace resolution. + instead( + "resolveSemanticColor", + tokenReference.default.meta ?? tokenReference.default.internal, + (args: any[], orig: any) => { + // If we don't have an active bunny theme, fall back to original resolver + if (!_colorRef.current) { + try { + console.debug( + "[themesystem] no active _colorRef, delegating to original resolver", + ); + } catch {} + return orig(...args); } - ]; - return () => unpatches.forEach(p => p()); + // Capture original theme arg for debugging / fallback usage + const originalThemeArg = args[0]; + + // Force the resolver to use the active bn-theme key so all semantic requests + // are resolved against our active theme. + args[0] = _colorRef.key; + + try { + console.debug( + `[themesystem] resolveSemanticColor called. originalTheme=${String(originalThemeArg)}, forcedTheme=${_colorRef.key}`, + ); + } catch {} + + // Use the internal reference (darker/light) to extract color defs + const themeReference = _colorRef.current!.reference; + const [name, colorDef] = extractInfo(themeReference, args[1]); + + // Try to resolve semantic from the active theme, with fallback mapping for spec 2 + let semanticDef = _colorRef.current.semantic[name]; + if ( + !semanticDef && + _colorRef.current.spec === 2 && + name in SEMANTIC_FALLBACK_MAP + ) { + semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]]; + } + + if (semanticDef?.value) { + try { + const out = + semanticDef.opacity === 1 + ? semanticDef.value + : chroma(semanticDef.value).alpha(semanticDef.opacity).hex(); + try { + console.debug( + `[themesystem] resolved semantic ${name} => ${out}`, + ); + } catch {} + return out; + } catch (e) { + // fallthrough to try raw or original + try { + console.warn( + "[themesystem] semantic resolution chroma failed", + e, + ); + } catch {} + } + } + + // Try raw value from theme + const rawValue = _colorRef.current.raw[colorDef.raw]; + if (rawValue) { + try { + const out = + colorDef.opacity === 1 + ? rawValue + : chroma(rawValue).alpha(colorDef.opacity).hex(); + try { + console.debug( + `[themesystem] resolved raw ${colorDef.raw} => ${out}`, + ); + } catch {} + return out; + } catch (e) { + try { + console.warn("[themesystem] raw resolution chroma failed", e); + } catch {} + } + } + + // If all fails, delegate back to original resolver. + // Attempt to call original resolver with the original theme argument to preserve expected behavior. + try { + try { + console.debug("[themesystem] falling back to original resolver"); + } catch {} + return orig(originalThemeArg, args[1]); + } catch (origErr) { + // As a last resort, call with the mutated args + try { + console.warn( + "[themesystem] original resolver call with original arg failed, calling orig with mutated args", + origErr, + ); + } catch {} + return orig(...args); + } + }, + ), + () => { + // Not the actual module but.. yeah. + Object.defineProperty(tokenReference, "RawColor", { + configurable: true, + writable: true, + value: _colorRef.origRaw, + }); + }, + ]; + + return () => unpatches.forEach((p) => p()); } -function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] { - // @ts-ignore - assigning to extractInfo._sym - const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]]; - const colorDef = tokenReference.SemanticColor[propName]; +function extractInfo( + themeName: string, + colorObj: any, +): [name: string, colorDef: any] { + // @ts-ignore - assigning to extractInfo._sym + const propName = + colorObj[(extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0])]; + const colorDef = tokenReference.SemanticColor[propName]; - return [propName, colorDef[themeName]]; + return [propName, colorDef[themeName]]; } diff --git a/src/lib/addons/themes/colors/updater.ts b/src/lib/addons/themes/colors/updater.ts index 7721628..c32efde 100644 --- a/src/lib/addons/themes/colors/updater.ts +++ b/src/lib/addons/themes/colors/updater.ts @@ -3,6 +3,19 @@ import { findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro"; import { parseColorManifest } from "./parser"; import { ColorManifest, InternalColorDefinition } from "./types"; +import chroma from "chroma-js"; +import { + ensureRawKeys as FB_ensureRawKeys, + ensureSemanticKeys as FB_ensureSemanticKeys, + mirrorGroups as FB_mirrorGroups, + extraSemanticNames as FB_extraSemanticNames, + SEMANTIC_FALLBACK_MAP as FB_SEMANTIC_FALLBACK_MAP, + AUTOCOMPLETE_FALLBACK_CANDIDATES as FB_AUTOCOMPLETE_FALLBACK_CANDIDATES, + TEXT_CANDIDATES as FB_TEXT_CANDIDATES, + REAPPLY_DELAYS as FB_REAPPLY_DELAYS, + FOOTER_KEY_REGEX, + softenHardFallback, +} from "./fallbacks"; const tokenRef = findByProps("SemanticColor"); const origRawColor = { ...tokenRef.RawColor }; @@ -13,47 +26,593 @@ const FormDivider = findByPropsLazy("DIVIDER_COLORS"); let _inc = 1; interface InternalColorRef { - key: `bn-theme-${string}`; - current: InternalColorDefinition | null; - readonly origRaw: Record; - lastSetDiscordTheme: string; + key: `bn-theme-${string}`; + current: InternalColorDefinition | null; + readonly origRaw: Record; + lastSetDiscordTheme: string; } /** @internal */ export const _colorRef: InternalColorRef = { - current: null, - key: `bn-theme-${_inc}`, - origRaw: origRawColor, - lastSetDiscordTheme: "darker" + current: null, + key: `bn-theme-${_inc}`, + origRaw: origRawColor, + lastSetDiscordTheme: "darker", }; -export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) { - if (settings.safeMode?.enabled) return; +export function updateBunnyColor( + colorManifest: ColorManifest | null, + { update = true }, +) { + if (settings.safeMode?.enabled) return; - const internalDef = colorManifest ? parseColorManifest(colorManifest) : null; - const ref = Object.assign(_colorRef, { - current: internalDef, - key: `bn-theme-${++_inc}`, - lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") - ? ThemeStore.theme - : _colorRef.lastSetDiscordTheme - }); + const internalDef = colorManifest ? parseColorManifest(colorManifest) : null; + const ref = Object.assign(_colorRef, { + current: internalDef, + key: `bn-theme-${++_inc}`, + lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") + ? ThemeStore.theme + : _colorRef.lastSetDiscordTheme, + }); - if (internalDef != null) { - tokenRef.Theme[ref.key.toUpperCase()] = ref.key; - FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference]; + if (internalDef != null) { + // Establish maps and canonical fallback helper at the top of the `internalDef` block so + // pickRawFallback / fallback logic is available consistently for all subsequent code. + const rawMap = internalDef.raw ?? {}; + const engineRaw = + _colorRef.origRaw && typeof _colorRef.origRaw === "object" + ? _colorRef.origRaw + : {}; - Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]); - Object.keys(tokenRef.SemanticColor).forEach(k => { - tokenRef.SemanticColor[k][ref.key] = { - ...tokenRef.SemanticColor[k][ref.current!.reference] + // Centralized helper used everywhere in this block to pick a sensible raw fallback. + // This prefers explicit theme raw entries, then engine/raw tables, then a surface-like key, + // and finally a softened fallback using chroma when possible. + const pickRawFallback = (semKey: string): string => { + if (rawMap[semKey]) return rawMap[semKey]; + const alt = semKey.replace(/^BG_|^BACKGROUND_/, ""); + if (rawMap[alt]) return rawMap[alt]; + if (rawMap[alt.toLowerCase()]) return rawMap[alt.toLowerCase()]; + if (engineRaw[semKey]) return engineRaw[semKey]; + const bg = Object.keys(rawMap).find((k) => + /BG|BACKGROUND|SURFACE/i.test(k), + ); + if (bg) return rawMap[bg]; + const fallback = + Object.values(rawMap)[0] || + Object.values(engineRaw)[0] || + tokenRef.RawColor["BACKGROUND_PRIMARY"] || + tokenRef.RawColor["BACKGROUND_FLOATING"] || + "#000000"; + try { + const c = chroma(String(fallback)); + const lum = c.luminance(); + if (lum > 0.6) return c.darken(1.4).hex(); + return c.hex(); + } catch { + return String(fallback); + } + }; + + tokenRef.Theme[ref.key.toUpperCase()] = ref.key; + FormDivider.DIVIDER_COLORS[ref.key] = + FormDivider.DIVIDER_COLORS[ref.current!.reference]; + + // Write raw colors directly into the runtime RawColor table for the active theme. + // This ensures lookups that access RawColor receive our theme's values. + try { + const rawMap = internalDef.raw ?? {}; + Object.keys(rawMap).forEach((rawKey) => { + try { + // Prefer theme-provided raw color; fall back to original raw color if missing. + const val = + rawMap[rawKey] ?? + _colorRef.origRaw[rawKey] ?? + tokenRef.RawColor[rawKey]; + if (typeof val === "string") { + tokenRef.RawColor[rawKey] = val; + } + } catch { + // best-effort: ignore individual key failures + } + }); + + // Ensure important quick-switcher / autocomplete and emoji picker raw/semantic keys exist in runtime tables. + // Some components read RawColor directly and some resolve SemanticColor entries — ensure both are filled. + try { + const ensureRawKeys = FB_ensureRawKeys; + + // A set of semantic keys we also want to ensure exist for our theme key + const ensureSemanticKeys = FB_ensureSemanticKeys; + + // Candidate lookup helper tries theme raw, original engine raw, tokenRef.RawColor, then a general fallback. + const candidateFor = (k: string) => { + return ( + rawMap[k] ?? + _colorRef.origRaw[k] ?? + tokenRef.RawColor[k] ?? + Object.values(rawMap)[0] ?? + Object.values(_colorRef.origRaw || {})[0] ?? + tokenRef.RawColor["BACKGROUND_PRIMARY"] ?? + "#000000" + ); + }; + + // pickRawFallback is defined at the start of the internalDef block (to ensure a single, canonical implementation). + // See the internalDef block for the canonical implementation that prefers engine/raw values and uses `chroma`. + + // Write raw keys so modules that read RawColor directly get a sensible value. + for (const k of ensureRawKeys) { + try { + const cand = candidateFor(k); + if (typeof cand === "string") tokenRef.RawColor[k] = cand; + } catch {} + } + + // Also populate SemanticColor runtime table entries for our injected theme key for important aliases. + for (const sem of ensureSemanticKeys) { + try { + // If the semantic entry object doesn't exist at all, create a minimal structure so consumers don't crash. + if (!tokenRef.SemanticColor[sem]) { + tokenRef.SemanticColor[sem] = {}; + } + + // If there's already a value for our key, skip. + if (tokenRef.SemanticColor[sem][ref.key]?.value) continue; + + // Prefer an existing semantic value for the engine reference (darker/light) if available. + const baseFromEngine = + tokenRef.SemanticColor[sem]?.[ref.current!.reference]; + + // fallback to a candidate raw color + const cand = candidateFor(sem) ?? pickRawFallback(sem); + + tokenRef.SemanticColor[sem][ref.key] = { + ...(baseFromEngine ?? {}), + value: typeof cand === "string" ? cand : String(cand), + opacity: 1, }; - }); + } catch {} + } + + // Aggressively also mirror some common emoji/quickbar aliases so components reading different names get consistent values. + try { + const mirrorGroups: Record = FB_mirrorGroups; + + for (const groupName of Object.keys(mirrorGroups)) { + const keys = mirrorGroups[groupName]; + // find the best candidate value (prefer semantic for current reference, then raw) + let best: string | undefined; + for (const k of keys) { + try { + const sem = + tokenRef.SemanticColor[k]?.[ref.current!.reference]?.value; + if (sem) { + best = sem; + break; + } + if (rawMap[k]) { + best = rawMap[k]; + break; + } + if (tokenRef.RawColor[k]) { + best = tokenRef.RawColor[k]; + break; + } + } catch {} + } + if (!best) + best = + Object.values(rawMap)[0] ?? + Object.values(_colorRef.origRaw || {})[0]; + + if (!best) continue; + + // Write best into RawColor and SemanticColor for each alias. + // For footer-specific aliases (emoji picker footer), prefer a lighter surface fallback + // (BACKGROUND_FLOATING or BACKGROUND_PRIMARY) when available to avoid an overly dark footer. + for (const k of keys) { + try { + // Determine the final value to write for this alias. + let chosen = best; + try { + if ( + /FOOTER|footer|PANEL|panel|POPUP/i.test(k) && + (tokenRef.RawColor["BACKGROUND_FLOATING"] || + tokenRef.RawColor["BACKGROUND_PRIMARY"]) + ) { + chosen = + tokenRef.RawColor["BACKGROUND_FLOATING"] || + tokenRef.RawColor["BACKGROUND_PRIMARY"] || + best; + } + } catch {} + + // Raw + tokenRef.RawColor[k] = chosen; + } catch {} + try { + if (!tokenRef.SemanticColor[k]) tokenRef.SemanticColor[k] = {}; + // preserve engine base when available, but override value/opactiy for our key + tokenRef.SemanticColor[k][ref.key] = { + ...(tokenRef.SemanticColor[k]?.[ref.current!.reference] ?? + {}), + value: tokenRef.SemanticColor[k]?.[ref.current!.reference] + ?.value + ? tokenRef.SemanticColor[k][ref.current!.reference].value + : (tokenRef.SemanticColor[k]?.[ref.key]?.value ?? best), + // if default engine opacity exists use it, else default to 1 + opacity: + tokenRef.SemanticColor[k]?.[ref.current!.reference] + ?.opacity ?? + tokenRef.SemanticColor[k]?.[ref.key]?.opacity ?? + 1, + }; + + // If semantic value for our injected key was just set to engine fallback or missing, + // ensure it uses the chosen raw color so resolveSemanticColor reads a consistent value. + try { + tokenRef.SemanticColor[k][ref.key].value = + tokenRef.SemanticColor[k][ref.key].value || best; + } catch {} + } catch {} + } + } + } catch {} + } catch {} + } catch { + // ignore failures while writing raw colors } - if (update) { - AppearanceManager.setShouldSyncAppearanceSettings(false); - AppearanceManager.updateTheme(internalDef != null ? ref.key : ref.lastSetDiscordTheme); + // Populate semantic tokens for our injected theme key so resolveSemanticColor + // and direct token table reads will get the correct values for each semantic token. + try { + const themeSemantic = internalDef.semantic ?? {}; + const rawMap = internalDef.raw ?? {}; + const engineRaw = + _colorRef.origRaw && typeof _colorRef.origRaw === "object" + ? _colorRef.origRaw + : {}; + + // Debug: announce start of semantic population for this theme key so we can trace issues + try { + // Use console.debug where available; fallback to console.log for environments that don't surface debug + if (typeof console !== "undefined" && console.debug) { + console.debug( + `[themes_v2] populating semantics for key=${String(ref.key)} reference=${String( + ref.current?.reference, + )} semanticCount=${Object.keys(themeSemantic).length} rawCount=${ + Object.keys(rawMap).length + }`, + ); + } else if (typeof console !== "undefined" && console.log) { + console.log( + `[themes_v2] populating semantics for key=${String(ref.key)} reference=${String( + ref.current?.reference, + )}`, + ); + } + } catch {} + + // A small explicit list of additional semantics that often are missing from themes + // and frequently cause visible light fallbacks (embeds, command/autocomplete bars, replies). + const extraSemanticNames = FB_extraSemanticNames; + + // helper to set one semantic key into runtime token table + const setSemanticKey = (semKey: string, value: string, opacity = 1) => { + try { + const base = + tokenRef.SemanticColor[semKey]?.[ref.current!.reference] ?? {}; + tokenRef.SemanticColor[semKey][ref.key] = { + ...base, + value, + opacity, + }; + } catch { + // best-effort per-key + } + }; + + // populate all runtime semantic keys based on canonical token list + const allSemanticKeys = Object.keys(tokenRef.SemanticColor || {}); + const canonicalKeys = new Set([ + ...allSemanticKeys, + ...extraSemanticNames, + ]); + + // pickRawFallback previously lived here but was moved earlier so it is available + // before any callers. Keep a minimal comment so line offsets remain clear. + // (Implementation moved above to avoid 'Cannot find name pickRawFallback' at use sites.) + + // fill semantics + for (const semKey of Array.from(canonicalKeys)) { + try { + // 1) exact semantic from theme (value/opactiy) + let semEntry = themeSemantic[semKey]; + if (semEntry && semEntry.value) { + setSemanticKey(semKey, semEntry.value, semEntry.opacity ?? 1); + continue; + } + + // Special-case: ensure autocomplete uses the same background as common UI bars + // (for example the You-bar / bot/avatar bar) and its text uses a readable text token + // when the theme doesn't provide them. Try multiple likely candidates (semantic then raw) + // so modules that expect different keys receive a sensible fallback instead of white. + if (!semEntry || !semEntry.value) { + if (semKey === "AUTOCOMPLETE_BACKGROUND") { + try { + const fallbackCandidates = FB_AUTOCOMPLETE_FALLBACK_CANDIDATES; + + let chosen: string | undefined; + for (const cand of fallbackCandidates) { + // 1) semantic table value for the current reference + const semVal = + tokenRef.SemanticColor[cand]?.[ref.current!.reference] + ?.value; + if (semVal) { + chosen = semVal; + break; + } + + // 2) theme-provided raw map + if (rawMap[cand]) { + chosen = rawMap[cand]; + break; + } + + // 3) runtime RawColor table + if (tokenRef.RawColor[cand]) { + chosen = tokenRef.RawColor[cand]; + break; + } + } + + if (!chosen) chosen = pickRawFallback(semKey); + // Ensure we always provide a string to the semantic table; fall back to a safe color. + setSemanticKey( + semKey, + String(chosen ?? pickRawFallback(semKey) ?? "#000000"), + 1, + ); + } catch { + // fallback to generic raw candidate if anything goes wrong + const candidate = pickRawFallback(semKey); + setSemanticKey(semKey, candidate, 1); + } + continue; + } + + if (semKey === "AUTOCOMPLETE_TEXT") { + try { + const textCandidates = FB_TEXT_CANDIDATES; + + let chosenText: string | undefined; + for (const cand of textCandidates) { + const semVal = + tokenRef.SemanticColor[cand]?.[ref.current!.reference] + ?.value; + if (semVal) { + chosenText = semVal; + break; + } + if (rawMap[cand]) { + chosenText = rawMap[cand]; + break; + } + if (tokenRef.RawColor[cand]) { + chosenText = tokenRef.RawColor[cand]; + break; + } + } + + if (!chosenText) chosenText = pickRawFallback("TEXT_NORMAL"); + const textOpacity = themeSemantic["TEXT_NORMAL"]?.opacity ?? 1; + // Coerce to string and ensure a safe fallback if undefined. + setSemanticKey( + semKey, + String( + chosenText ?? pickRawFallback("TEXT_NORMAL") ?? "#000000", + ), + textOpacity, + ); + } catch { + const candidate = pickRawFallback(semKey); + setSemanticKey(semKey, candidate, 1); + } + continue; + } + } + + // 2) try some reasonable alternate names (strip common prefixes) + const alternates = [ + semKey, + semKey.replace(/^BACKGROUND_/, ""), + semKey.replace(/^BG_/, ""), + semKey.replace(/^TEXT_/, ""), + semKey.replace(/_COLOR$/, ""), + ]; + let foundAlt = false; + for (const alt of alternates) { + if (themeSemantic[alt] && themeSemantic[alt].value) { + const altEntry = themeSemantic[alt]; + setSemanticKey(semKey, altEntry.value, altEntry.opacity ?? 1); + foundAlt = true; + break; + } + } + if (foundAlt) continue; + + // 3) try fallback mapping (common name differences) + const SEMANTIC_FALLBACK_MAP: Record = + FB_SEMANTIC_FALLBACK_MAP; + if (semKey in SEMANTIC_FALLBACK_MAP) { + const alt = SEMANTIC_FALLBACK_MAP[semKey]; + const altEntry = themeSemantic[alt]; + if (altEntry && altEntry.value) { + setSemanticKey(semKey, altEntry.value, altEntry.opacity ?? 1); + continue; + } + } + + // 4) derive fallback from raw colors and bias for dark-friendly appearance + let candidate = pickRawFallback(semKey); + try { + const c = chroma(candidate); + const lum = c.luminance(); + if (lum > 0.6) candidate = c.darken(1.4).hex(); + else candidate = c.hex(); + } catch { + // keep candidate as-is + } + setSemanticKey(semKey, candidate, 1); + } catch (e) { + // per-key should not break everything + // eslint-disable-next-line no-console + console.warn("themes_v2: failed to populate semantic", semKey, e); + } + } + + // After initial population, schedule a reapply sequence to catch modules that initialize slightly later. + // Use multiple attempts with backoff and debug logs so we can observe late-initializing modules. + try { + const reapplyDelays = FB_REAPPLY_DELAYS; + let attempt = 0; + + const reapply = () => { + attempt++; + try { + if (typeof console !== "undefined" && console.debug) { + console.debug( + `[themes_v2] semantic reapply attempt ${attempt}/${reapplyDelays.length} for key=${String( + ref.key, + )}`, + ); + } + } catch {} + + try { + // Reapply raw values (some modules read RawColor directly after init) + const rawMap = internalDef.raw ?? {}; + Object.keys(rawMap).forEach((rawKey) => { + try { + const val = + rawMap[rawKey] ?? + _colorRef.origRaw[rawKey] ?? + tokenRef.RawColor[rawKey]; + if (typeof val === "string") tokenRef.RawColor[rawKey] = val; + } catch {} + }); + + // Reapply semantic table values for our key (idempotent) + let appliedCount = 0; + for (const semKey of Array.from(canonicalKeys)) { + try { + // If our key already has a value, keep it; otherwise re-run pickRawFallback + const existing = tokenRef.SemanticColor[semKey]?.[ref.key]; + if (!existing || !existing.value) { + let candidate = pickRawFallback(semKey); + try { + const c = chroma(candidate); + const lum = c.luminance(); + if (lum > 0.6) candidate = c.darken(1.4).hex(); + else candidate = c.hex(); + } catch {} + tokenRef.SemanticColor[semKey][ref.key] = { + ...(tokenRef.SemanticColor[semKey]?.[ + ref.current!.reference + ] ?? {}), + value: candidate, + opacity: 1, + }; + appliedCount++; + } + } catch (e) { + // per-key should not break everything; log debug if available + try { + if (typeof console !== "undefined" && console.debug) { + console.debug( + `[themes_v2] failed per-key reapply for ${semKey} on attempt ${attempt}`, + e, + ); + } + } catch {} + } + } + + try { + if (typeof console !== "undefined" && console.debug) { + console.debug( + `[themes_v2] reapply attempt ${attempt} complete for key=${String( + ref.key, + )} applied=${appliedCount}`, + ); + } + } catch {} + } catch (e) { + // eslint-disable-next-line no-console + console.warn("themes_v2: reapply semantics failed", e); + } + + // Schedule next attempt (if any) + if (attempt < reapplyDelays.length) { + try { + setTimeout(reapply, reapplyDelays[attempt]); + } catch {} + } + }; + + // Start first reapply after initial delay + try { + setTimeout(reapply, reapplyDelays[0]); + } catch { + // ignore setTimeout failures on weird runtimes + } + } catch { + // ignore unexpected failures in reapply orchestration + } + } catch (e) { + // overall failure shouldn't break + // eslint-disable-next-line no-console + console.error("themes_v2: semantic population failed", e); } + + // Also ensure Shadow tokens for our key are populated from the reference so shadow lookups don't fallback unexpectedly + try { + Object.keys(tokenRef.Shadow).forEach( + (k) => + (tokenRef.Shadow[k][ref.key] = + tokenRef.Shadow[k][ref.current!.reference]), + ); + } catch { + // ignore + } + } + + if (update) { + AppearanceManager.setShouldSyncAppearanceSettings(false); + AppearanceManager.updateTheme( + internalDef != null ? ref.key : ref.lastSetDiscordTheme, + ); + + // Force additional reapply(s) shortly after token population completes so modules that + // read theme values slightly later will pick up our injected theme key. + // These are best-effort and wrapped in try/catch to avoid crashing in odd runtimes. + try { + setTimeout(() => { + try { + AppearanceManager.updateTheme( + internalDef != null ? ref.key : ref.lastSetDiscordTheme, + ); + } catch {} + }, 350); + + // Second reapply with longer delay for very late initializers + setTimeout(() => { + try { + AppearanceManager.updateTheme( + internalDef != null ? ref.key : ref.lastSetDiscordTheme, + ); + } catch {} + }, 1200); + } catch {} + } } - diff --git a/src/lib/addons/themes/index.ts b/src/lib/addons/themes/index.ts index 546c040..e37ccc3 100644 --- a/src/lib/addons/themes/index.ts +++ b/src/lib/addons/themes/index.ts @@ -1,160 +1,537 @@ -import { awaitStorage, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; +import { + awaitStorage, + createFileBackend, + createMMKVBackend, + createStorage, + wrapSync, +} from "@core/vendetta/storage"; import { writeFile } from "@lib/api/native/fs"; -import { getStoredTheme, getThemeFilePath, isPyonLoader, isThemeSupported } from "@lib/api/native/loader"; +import { + getStoredTheme, + getThemeFilePath, + isPyonLoader, + isThemeSupported, +} from "@lib/api/native/loader"; import { awaitStorage as newAwaitStorage } from "@lib/api/storage"; import { safeFetch } from "@lib/utils"; import { Platform } from "react-native"; import initColors from "./colors"; -import { applyAndroidAlphaKeys, normalizeToHex } from "./colors/parser"; import { colorsPref } from "./colors/preferences"; -import { VendettaThemeManifest } from "./colors/types"; import { updateBunnyColor } from "./colors/updater"; +import { applyAndroidAlphaKeys, normalizeToHex } from "./colors/parser"; +import { + VendettaThemeManifest, + BunnyColorManifest, + InternalColorDefinition, + ColorManifest, +} from "./colors/types"; -export interface VdThemeInfo { - id: string; - selected: boolean; - data: VendettaThemeManifest; -} - -export const themes = wrapSync(createStorage>(createMMKVBackend("VENDETTA_THEMES"))); +import { findByProps } from "@metro"; +import chroma from "chroma-js"; /** - * @internal + * Theme v2 - canonical theme manager (full replacement) + * + * Goals: + * - Provide a canonical internal representation for both spec v2 (Vendetta) and spec v3 + * - Always fill missing semantic tokens deterministically (augment) and persist augmented themes + * - Replace the resolver/updater behavior so components always receive sensible tokens + * - Keep public API backward-compatible with previous `@lib/addons/themes` exports + * + * This file implements the manager, parser/normalizer, augmentation and high-level API + * as a single, self-contained replacement. */ -export async function writeThemeToNative(theme: VdThemeInfo | {}) { - if (typeof theme !== "object") throw new Error("Theme must be an object"); - // Save the current theme as current-theme.json. When supported by loader, - // this json will be written to appropriate path and be used to theme the native side. - await createFileBackend(getThemeFilePath() || "theme.json").set(theme); +/* Types */ +export interface VdThemeInfo { + id: string; + selected: boolean; + // Store the original ColorManifest (spec 3 / vendetta-compatible manifest). + // This ensures the rest of the codebase that expects a ColorManifest + // (for example `initColors`) receives the correct shape. + data: ColorManifest; } -// Process data for some compatiblity with native side -function processData(data: VendettaThemeManifest) { - if (data.semanticColors) { - const { semanticColors } = data; +/* Storage wrapper for installed themes */ +export const themes = wrapSync( + createStorage>( + // Keep the original storage key so existing installations migrate in-place. + createMMKVBackend("VENDETTA_THEMES"), + ), +); - for (const key in semanticColors) { - for (const index in semanticColors[key]) { - semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string) || false; - } +/* Helpers */ + +/** + * Normalize and canonicalize an incoming theme manifest (v2 or v3) into InternalColorDefinition. + * This function also normalizes color strings to hex and applies android-specific keys when necessary. + */ +function canonicalizeManifest( + manifest: ColorManifest, +): InternalColorDefinition { + // Helper to normalize color values + function normColor(v: any) { + if (typeof v !== "string") return undefined; + return normalizeToHex(v); + } + + // For v3-like BunnyColorManifest + if ((manifest as BunnyColorManifest).main) { + const b = manifest as BunnyColorManifest; + const main = b.main; + + // Gather raw map + const raw: Record = {}; + for (const k of Object.keys(main.raw ?? {})) { + const n = normColor(main.raw?.[k]); + if (n) raw[k] = n; + } + + if (Platform.OS === "android") applyAndroidAlphaKeys(raw); + + // Parse semantic + const semantic: Record = {}; + for (const k of Object.keys(main.semantic ?? {})) { + const v = main.semantic![k]; + if (typeof v === "string") { + semantic[k] = { value: normColor(v) ?? v, opacity: 1 }; + } else if (typeof v === "object" && v !== null) { + if (v.type === "raw") + semantic[k] = { + value: normColor(v.value) ?? v.value, + opacity: v.opacity ?? 1, + }; + else + semantic[k] = { + value: normColor(tokenRaw(v.value)) ?? tokenRaw(v.value), + opacity: v.opacity ?? 1, + }; + } + } + + return { + spec: 3, + reference: + (colorsPref.type ?? b.main.type) === "dark" ? "darker" : "light", + semantic, + raw, + background: main.background, + } as InternalColorDefinition; + } + + // For v2 VendettaThemeManifest + const v = manifest as VendettaThemeManifest; + const raw: Record = {}; + for (const k of Object.keys(v.rawColors ?? {})) { + const n = normColor(v.rawColors![k]); + if (n) raw[k] = n; + } + + if (Platform.OS === "android") applyAndroidAlphaKeys(raw); + + const semantic: Record = {}; + if (v.semanticColors) { + // Vendetta gives arrays of color choices [dark, light] + const type = (colorsPref.type ?? "dark") === "dark" ? 0 : 1; + for (const k of Object.keys(v.semanticColors)) { + const arr = v.semanticColors[k] as (string | false)[]; + const chosen = arr[type] || arr[0] || undefined; + if (chosen) + semantic[k] = { + value: normalizeToHex(chosen) ?? (chosen as any), + opacity: 1, + }; + } + } + + return { + spec: 2, + reference: (colorsPref.type ?? "dark") === "dark" ? "darker" : "light", + semantic, + raw, + background: v.background + ? { + url: v.background.url, + opacity: v.background.alpha ?? 1, + blur: v.background.blur, } - } - - if (data.rawColors) { - const { rawColors } = data; - - for (const key in rawColors) { - const normalized = normalizeToHex(rawColors[key]); - if (normalized) data.rawColors[key] = normalized; - } - - if (Platform.OS === "android") applyAndroidAlphaKeys(rawColors); - } - - // this field is required by the spec but vd seems to ignore this - // so are most vd themes - data.spec ??= 2; - - return data; + : undefined, + } as InternalColorDefinition; } -function validateTheme(themeJSON: any): boolean { - if (typeof themeJSON !== "object" || themeJSON === null) return false; - if (themeJSON.spec !== 2 && themeJSON.spec !== 3) return false; - if (themeJSON.spec === 3 && !themeJSON.main) return false; - - return true; -} - -export async function fetchTheme(url: string, selected = false) { - let themeJSON: any; - - try { - themeJSON = await (await safeFetch(url, { cache: "no-store" })).json(); - } catch { - throw new Error(`Failed to fetch theme at ${url}`); - } - - // Validate theme - if (!validateTheme(themeJSON)) throw new Error(`Invalid theme at ${url}`); - - themes[url] = { - id: url, - selected: selected, - data: processData(themeJSON), - }; - - if (selected) { - writeThemeToNative(themes[url]); - updateBunnyColor(themes[url].data, { update: true }); - } -} - -export async function installTheme(url: string) { - if (typeof url !== "string" || url in themes) throw new Error("Theme already installed"); - await fetchTheme(url); -} - -export function selectTheme(theme: VdThemeInfo | null, write = true) { - if (theme) theme.selected = true; - Object.keys(themes).forEach( - k => themes[k].selected = themes[k].id === theme?.id - ); - - if (theme == null && write) { - updateBunnyColor(null, { update: true }); - return writeThemeToNative({}); - } else if (theme) { - updateBunnyColor(theme.data, { update: true }); - return writeThemeToNative(theme); - } -} - -export async function removeTheme(id: string) { - const theme = themes[id]; - if (theme.selected) await selectTheme(null); - delete themes[id]; - - return theme.selected; -} - -export async function updateThemes() { - await awaitStorage(themes); - const currentTheme = getThemeFromLoader(); - await Promise.allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id))); -} - -export function getCurrentTheme() { - return Object.values(themes).find(t => t.selected) ?? null; +/* tokenRaw: helper to get runtime token raw color if available */ +function tokenRaw(key: string): string | undefined { + try { + const tokenRef: any = findByProps("SemanticColor"); + return tokenRef?.RawColor?.[key]; + } catch { + return undefined; + } } /** - * @internal + * Augment a canonical theme's semantic map by filling any missing semantic entries. + * Augmentation is deterministic: for each missing semantic token we select a raw fallback + * and bias it towards dark mode if necessary. + * + * This function mutates the provided `data` object and returns it. + */ +function augmentMissingSemantics(data: InternalColorDefinition) { + data.semantic = data.semantic ?? {}; + const rawMap = { ...(data.raw ?? {}) }; + + // merge any native RawColor reference as fallback candidates + try { + const tokenRef: any = findByProps("SemanticColor") ?? {}; + const engineRaw = tokenRef?.RawColor ?? {}; + for (const k of Object.keys(engineRaw)) { + if (!(k in rawMap)) rawMap[k] = engineRaw[k]; + } + } catch { + // ignore + } + + const tokenRefAny: any = findByProps("SemanticColor") ?? {}; + const canonicalSemanticNames: string[] = tokenRefAny?.SemanticColor + ? Object.keys(tokenRefAny.SemanticColor) + : Object.keys(data.semantic).slice(0, 50); + + // Utility to choose fallback raw color for a semantic name + const chooseFallbackRaw = (semanticName: string) => { + // direct mapping + if (rawMap[semanticName]) return rawMap[semanticName]; + + // try simplified forms + const simplified = semanticName.replace(/^BG_|^BACKGROUND_/, ""); + if (rawMap[simplified]) return rawMap[simplified]; + const lower = simplified.toLowerCase(); + if (rawMap[lower]) return rawMap[lower]; + if (rawMap[simplified.toUpperCase()]) + return rawMap[simplified.toUpperCase()]; + + // pick a background-like key + const bgKey = Object.keys(rawMap).find((k) => + /BG|BACKGROUND|SURFACE/i.test(k), + ); + if (bgKey) return rawMap[bgKey]; + + // fallback to any raw color + const any = Object.values(rawMap)[0]; + if (any) return any; + + // last resort hardcoded + return "#0f0f0f"; + }; + + // Chromatic adjustment function for safety in dark mode (ensure not too light) + const adjustForDark = (hex: string) => { + try { + const c = chroma(hex); + const lum = c.luminance(); + if (lum > 0.6) return c.darken(1.4).hex(); + return c.hex(); + } catch { + return hex; + } + }; + + for (const sem of canonicalSemanticNames) { + const existing = data.semantic?.[sem]; + if (existing && existing.value) continue; // don't overwrite existing semantics + + const rawCandidate = chooseFallbackRaw(sem); + const final = adjustForDark(rawCandidate); + data.semantic[sem] = { value: final, opacity: 1 }; + } + + return data; +} + +/** + * Persist an internal theme object to storage (overwrites existing stored manifest). + * This function converts InternalColorDefinition back to a safe, serializable manifest. + */ +async function persistAugmentedTheme( + id: string, + data: InternalColorDefinition, +) { + // We store the canonical internal structure as-is under the same id so callers read consistent data + themes[id] = { id, selected: themes[id]?.selected ?? false, data }; + await awaitStorage(themes); +} + +/** + * Write the current theme to native loader path (best-effort). + * + * NOTE: This function now writes the canonical ColorManifest (spec-3-like) + * structure so native side receives the manifest format expected by initColors. + */ +export async function writeThemeToNative(theme: ColorManifest | {}) { + if (typeof theme !== "object") throw new Error("Theme must be an object"); + await createFileBackend(getThemeFilePath() || "theme.json").set(theme); +} + +/* Public API functions (compatible names and behavior) */ + +/** + * Fetch a theme manifest from a URL and install it (canonicalize + persist). + */ +export async function fetchTheme(url: string, selected = false) { + let themeJSON: any; + try { + themeJSON = await (await safeFetch(url, { cache: "no-store" })).json(); + } catch (err) { + throw new Error(`Failed to fetch theme at ${url}`); + } + + // Accept vendetta spec v2 or bunny spec v3; validate loosely + if ( + !themeJSON || + typeof themeJSON !== "object" || + (themeJSON.spec !== 2 && themeJSON.spec !== 3) + ) { + throw new Error(`Invalid theme at ${url}`); + } + + // Canonicalize and augment semantics (always run augmentation per your request) + const canonical = canonicalizeManifest(themeJSON as ColorManifest); + augmentMissingSemantics(canonical); + + // Persist augmented canonical theme into storage + themes[url] = { id: url, selected, data: canonical }; + await persistAugmentedTheme(url, canonical); + + // If selected, apply immediately + if (selected) { + updateBunnyColor(canonical, { update: true }); + await writeThemeToNative(canonical); + } +} + +/** + * Install theme (safe guard) + */ +export async function installTheme(url: string) { + if (typeof url !== "string" || url in themes) + throw new Error("Theme already installed"); + await fetchTheme(url); +} + +/** + * Select a theme to be active. + * This will ensure augmentation exists, persist it, update updater and write native theme. + */ +export function selectTheme(theme: VdThemeInfo | null, write = true) { + if (theme) theme.selected = true; + Object.keys(themes).forEach( + (k) => (themes[k].selected = themes[k].id === theme?.id), + ); + + if (theme == null) { + // Clear active theme + updateBunnyColor(null, { update: true }); + if (write) return writeThemeToNative({}); + return; + } + + // Ensure canonical augmentation exists for this theme and persist + const info = themes[theme.id]; + try { + const data = info.data; + augmentMissingSemantics(data); // idempotent + // persist augmented data + void persistAugmentedTheme(theme.id, data); + // Apply to runtime + updateBunnyColor(data, { update: true }); + if (write) return writeThemeToNative(data); + } catch (e) { + // On any error, fallback to applying raw theme data if available + try { + const fallback = theme.data; + updateBunnyColor(fallback, { update: true }); + if (write) return writeThemeToNative(fallback); + } catch { + // swallow + } + } +} + +/** + * Remove a theme (and clear selection if it was active). + */ +export async function removeTheme(id: string) { + const theme = themes[id]; + if (theme?.selected) await selectTheme(null); + delete themes[id]; + await awaitStorage(themes); + return theme?.selected ?? false; +} + +/** + * Update (re-fetch) all installed themes in-place, re-canonicalizing and re-augmenting. + */ +export async function updateThemes() { + await awaitStorage(themes); + const current = getThemeFromLoader(); + await Promise.allSettled( + Object.keys(themes).map(async (id) => { + try { + // re-fetch original location (if it's a URL) to get fresh manifest; otherwise skip + if (id.startsWith("http")) { + await fetchTheme(id, current?.id === id); + } else { + // re-augment stored data in-place + const entry = themes[id]; + if (entry && entry.data) { + augmentMissingSemantics(entry.data); + await persistAugmentedTheme(id, entry.data); + if (current?.id === id) { + updateBunnyColor(entry.data, { update: true }); + await writeThemeToNative(entry.data); + } + } + } + } catch (e) { + // log and continue + // eslint-disable-next-line no-console + console.error("themes_v2: update failed for", id, e); + } + }), + ); +} + +/** + * Returns currently selected theme or null + */ +export function getCurrentTheme() { + return Object.values(themes).find((t) => t.selected) ?? null; +} + +/** + * Read theme directly from loader (native-side stored theme) */ export function getThemeFromLoader(): VdThemeInfo | null { - return getStoredTheme(); + const stored = getStoredTheme(); + if (!stored) return null; + // stored may be a raw manifest - attempt to canonicalize + try { + const canonical = canonicalizeManifest(stored as ColorManifest); + return { id: "loader-theme", selected: false, data: canonical }; + } catch { + return null; + } } /** - * @internal + * Initialize themes at startup: canonicalize existing, init color patches and write native. */ export async function initThemes() { - if (!isThemeSupported()) return; + if (!isThemeSupported()) return; - try { - if (isPyonLoader()) { - writeFile("../vendetta_theme.json", "null"); - } - - await newAwaitStorage(colorsPref); - - const currentTheme = getThemeFromLoader(); - initColors(currentTheme?.data ?? null); - - updateThemes().catch(e => console.error("Failed to update themes", e)); - } catch (e) { - console.error("Failed to initialize themes", e); + // Try to perform the minimal, fast startup steps first so the app isn't blocked. + try { + if (isPyonLoader()) { + try { + writeFile("../vendetta_theme.json", "null"); + } catch (e) { + // non-fatal + console.error("themes_v2: pyon write failed", e); + } } + } catch (e) { + console.error("themes_v2: early init error", e); + } + + // Ensure colors preference backend is available (fast) + try { + await newAwaitStorage(colorsPref); + } catch (e) { + console.error("themes_v2: failed to await colorsPref storage", e); + } + + // Immediately apply whatever theme the user last selected (if any). + // This makes the app show the correct theme on first load without waiting for background migration. + try { + const curEntry = getCurrentTheme(); + if (curEntry && curEntry.data) { + try { + // Ensure missing semantics are present (idempotent) + augmentMissingSemantics(curEntry.data as any); + } catch (e) { + console.warn("themes_v2: augment on startup failed", e); + } + + try { + // Apply to runtime token tables (fast) so UI reflects the theme immediately. + updateBunnyColor(curEntry.data as any, { update: true }); + } catch (e) { + console.error("themes_v2: updateBunnyColor on startup failed", e); + } + + // Try to write native theme but do not block startup on its completion. + writeThemeToNative(curEntry.data as any).catch((e) => { + console.error("themes_v2: writeThemeToNative (startup) failed", e); + }); + } else { + // If no selected entry, try to initialize colors from loader theme quickly. + try { + const loader = getThemeFromLoader(); + // initColors expects a ColorManifest | null - pass loader data if available. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + initColors(loader?.data ?? null); + } catch (e) { + console.error("themes_v2: initColors failed on startup", e); + } + } + } catch (e) { + console.error("themes_v2: apply selected theme failed", e); + } + + // Perform heavier work (migrations, full updates) in the background so startup isn't blocked. + (async () => { + try { + // Canonicalize and persist all installed themes safely in background. + const ids = Object.keys(themes); + for (const id of ids) { + try { + const entry = themes[id]; + if (!entry || !entry.data) continue; + augmentMissingSemantics(entry.data as any); + await persistAugmentedTheme(id, entry.data as any); + } catch (e) { + console.error("themes_v2: background canonicalize failed for", id, e); + } + } + + // Re-fetch remote manifests and re-apply if current changed or needs refresh. + try { + await updateThemes(); + } catch (e) { + console.error("themes_v2: background updateThemes failed", e); + } + + // Ensure the currently selected theme is fully applied after migrations finish. + try { + const finalCur = getCurrentTheme(); + if (finalCur && finalCur.data) { + augmentMissingSemantics(finalCur.data as any); + updateBunnyColor(finalCur.data as any, { update: true }); + try { + await writeThemeToNative(finalCur.data as any); + } catch (e) { + console.error( + "themes_v2: writeThemeToNative (background) failed", + e, + ); + } + } + } catch (e) { + console.error("themes_v2: final apply after background work failed", e); + } + } catch (e) { + console.error("themes_v2: background migration task failed", e); + } + })(); } diff --git a/src/modules.d.ts b/src/modules.d.ts index 25d9432..cca2107 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -1,5 +1,5 @@ declare module "bunny-build-info" { - const version: "1.1.0-1"; + const version: "1.1.0-2"; } declare module "*.png" {