Add theme color fallback helpers and improve updater logic

- Add fallbacks.ts with centralized fallback lists and helpers -
Refactor updater to use fallback helpers for missing tokens - Enhance
semantic and raw color population for theme injection - Improve theme
manager to canonicalize and augment themes on install - Update resolver
to add debug logs and fallback handling - Bump bunny-build-info version
to 1.1.0-2
This commit is contained in:
kmmiio99o 2025-10-12 12:43:43 +02:00
parent c01d0c98a4
commit 3842884cd2
6 changed files with 1465 additions and 225 deletions

View file

@ -74,7 +74,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.0-1"`,
version: `"1.1.0-2"`,
},
},
},

View file

@ -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<string, string[]> = {
// 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<string, string> = {
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;
}
}

View file

@ -10,80 +10,164 @@ const tokenReference = findByProps("SemanticColor");
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
"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]];
}

View file

@ -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<string, string>;
lastSetDiscordTheme: string;
key: `bn-theme-${string}`;
current: InternalColorDefinition | null;
readonly origRaw: Record<string, string>;
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<string, string[]> = 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<string, string> =
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 {}
}
}

View file

@ -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<Record<string, VdThemeInfo>>(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<Record<string, VdThemeInfo>>(
// 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<string, string> = {};
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<string, { value: string; opacity: number }> = {};
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<string, string> = {};
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<string, { value: string; opacity: number }> = {};
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);
}
})();
}

2
src/modules.d.ts vendored
View file

@ -1,5 +1,5 @@
declare module "bunny-build-info" {
const version: "1.1.0-1";
const version: "1.1.0-2";
}
declare module "*.png" {