mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
f2fa9c39a9
Theme updates
1142 lines
35 KiB
TypeScript
1142 lines
35 KiB
TypeScript
import localforage from "localforage";
|
|
import browser from "webextension-polyfill";
|
|
import {
|
|
type CustomTheme,
|
|
getForcedDarkMode,
|
|
type LoadedCustomTheme,
|
|
shouldForceThemeAppearance,
|
|
} from "@/types/CustomThemes";
|
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|
import debounce from "@/seqta/utils/debounce";
|
|
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
|
import {
|
|
clearCustomThemeAdaptiveCssVariables,
|
|
setCustomThemeAdaptiveCssVariables,
|
|
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
|
|
|
type ThemeContent = {
|
|
id: string;
|
|
name: string;
|
|
coverImage?: string; // base64, optional
|
|
description: string;
|
|
defaultColour?: string;
|
|
CanChangeColour?: boolean;
|
|
CustomCSS?: string;
|
|
hideThemeName?: boolean;
|
|
forceDark?: boolean;
|
|
images?: { id: string; variableName: string; data: string }[]; // data: base64
|
|
};
|
|
|
|
export type InstallThemeMeta = {
|
|
fromStore: boolean;
|
|
/** Server list `updated_at` (Unix seconds); set when installing from store. */
|
|
serverUpdatedAtSec?: number;
|
|
forceTheme?: boolean;
|
|
adaptiveCssVariables?: string[];
|
|
images: { id: string; variableName: string; data: string }[]; // data: base64
|
|
};
|
|
|
|
export class ThemeManager {
|
|
private static instance: ThemeManager;
|
|
private currentTheme: CustomTheme | null = null;
|
|
private styleElement: HTMLStyleElement | null = null;
|
|
private previewStyleElement: HTMLStyleElement | null = null;
|
|
private previousImageVariableNames: string[] = [];
|
|
private originalPreviewColor: string | null = null;
|
|
private originalPreviewTheme: boolean | null = null;
|
|
private imageUrlCache: Map<string, string> = new Map();
|
|
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
|
|
private storeUpdateCheckRunning = false;
|
|
|
|
private constructor() {
|
|
console.debug("[ThemeManager] Initializing...");
|
|
}
|
|
|
|
public static getInstance(): ThemeManager {
|
|
if (!ThemeManager.instance) {
|
|
ThemeManager.instance = new ThemeManager();
|
|
}
|
|
return ThemeManager.instance;
|
|
}
|
|
|
|
/**
|
|
* Get the currently active theme
|
|
*/
|
|
public getCurrentTheme(): CustomTheme | null {
|
|
return this.currentTheme;
|
|
}
|
|
|
|
/**
|
|
* Get a theme by ID from storage
|
|
*/
|
|
public async getTheme(themeId: string): Promise<CustomTheme | null> {
|
|
console.debug("[ThemeManager] Getting theme:", themeId);
|
|
try {
|
|
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
|
return theme;
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error getting theme:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the ID of the currently selected theme
|
|
*/
|
|
public getSelectedThemeId(): string {
|
|
return settingsState.selectedTheme;
|
|
}
|
|
|
|
/**
|
|
* Update the last transition point based on a click or event
|
|
*/
|
|
public setTransitionPoint(x: number, y: number): void {
|
|
this.lastTransitionPoint = { x, y };
|
|
}
|
|
|
|
/**
|
|
* Apply a view transition animation
|
|
*/
|
|
private async applyViewTransition(callback: () => void): Promise<void> {
|
|
if (
|
|
!document.startViewTransition ||
|
|
!settingsState.animations ||
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
) {
|
|
// Just run the callback without animation if transitions not supported
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
// Use last known transition point or fallback to center
|
|
const x = this.lastTransitionPoint.x || window.innerWidth / 2;
|
|
const y = this.lastTransitionPoint.y || window.innerHeight / 2;
|
|
const right = window.innerWidth - x;
|
|
const bottom = window.innerHeight - y;
|
|
|
|
const maxRadius = Math.hypot(
|
|
Math.max(x, right),
|
|
Math.max(y, bottom),
|
|
);
|
|
|
|
await document.startViewTransition(() => {
|
|
callback();
|
|
}).ready;
|
|
|
|
try {
|
|
document.documentElement.animate(
|
|
{
|
|
clipPath: [
|
|
`circle(0px at ${x}px ${y}px)`,
|
|
`circle(${maxRadius}px at ${x}px ${y}px)`,
|
|
],
|
|
},
|
|
{
|
|
duration: 400,
|
|
easing: 'ease-in-out',
|
|
pseudoElement: '::view-transition-new(root)',
|
|
}
|
|
);
|
|
} catch (error) {
|
|
console.error("[ThemeManager] View Transition animation error:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable the current theme without deleting it
|
|
*/
|
|
public async disableTheme(): Promise<void> {
|
|
console.debug("[ThemeManager] Disabling current theme");
|
|
try {
|
|
if (!this.currentTheme) {
|
|
console.debug("[ThemeManager] No theme to disable");
|
|
return;
|
|
}
|
|
|
|
await this.removeTheme(this.currentTheme);
|
|
this.currentTheme = null;
|
|
settingsState.selectedTheme = "";
|
|
console.debug("[ThemeManager] Theme disabled successfully");
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error disabling theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the theme system and restore previous state
|
|
*/
|
|
public async initialize(): Promise<void> {
|
|
console.debug("[ThemeManager] Starting initialization");
|
|
try {
|
|
const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad";
|
|
const migrationCSS = "#title {\nbackground: transparent !important;\n}";
|
|
|
|
const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null;
|
|
if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) {
|
|
theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS;
|
|
await localforage.setItem(neumorphicThemeId, theme);
|
|
}
|
|
|
|
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
|
|
if (themeCreatorOpen === "true") {
|
|
console.debug(
|
|
"[ThemeManager] Theme creator was open, clearing preview state",
|
|
);
|
|
this.clearPreview();
|
|
localStorage.removeItem("themeCreatorOpen");
|
|
}
|
|
|
|
if (settingsState.selectedTheme) {
|
|
console.debug(
|
|
"[ThemeManager] Found selected theme, restoring:",
|
|
settingsState.selectedTheme,
|
|
);
|
|
await this.setTheme(settingsState.selectedTheme, false);
|
|
}
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error during initialization:", error);
|
|
} finally {
|
|
void this.checkStoreThemeUpdates();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up theme system resources
|
|
*/
|
|
public async cleanup(): Promise<void> {
|
|
console.debug("[ThemeManager] Cleaning up resources");
|
|
try {
|
|
if (this.currentTheme) {
|
|
await this.removeTheme(this.currentTheme, false);
|
|
}
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error during cleanup:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set and apply a theme by ID
|
|
*/
|
|
public async setTheme(themeId: string, applyViewTransition: boolean = true): Promise<void> {
|
|
console.debug("[ThemeManager] Setting theme:", themeId);
|
|
try {
|
|
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
|
if (!theme) {
|
|
console.error("[ThemeManager] Theme not found:", themeId);
|
|
return;
|
|
}
|
|
|
|
// Store original settings before applying new theme
|
|
if (!settingsState.selectedTheme) {
|
|
console.debug("[ThemeManager] Storing original settings");
|
|
settingsState.originalSelectedColor = settingsState.selectedColor;
|
|
|
|
if (shouldForceThemeAppearance(theme)) {
|
|
settingsState.originalDarkMode = settingsState.DarkMode;
|
|
}
|
|
}
|
|
|
|
// Use view transition for the theme change
|
|
if (applyViewTransition) {
|
|
await this.applyViewTransition(async () => {
|
|
// Remove current theme if exists
|
|
if (this.currentTheme) {
|
|
console.debug("[ThemeManager] Removing current theme");
|
|
await this.removeThemeWithoutTransition(this.currentTheme);
|
|
}
|
|
|
|
// Apply new theme
|
|
await this.applyTheme(theme);
|
|
this.currentTheme = theme;
|
|
settingsState.selectedTheme = themeId;
|
|
});
|
|
} else {
|
|
// Remove current theme if exists
|
|
if (this.currentTheme) {
|
|
console.debug("[ThemeManager] Removing current theme");
|
|
await this.removeThemeWithoutTransition(this.currentTheme);
|
|
}
|
|
|
|
// Apply new theme
|
|
await this.applyTheme(theme);
|
|
this.currentTheme = theme;
|
|
settingsState.selectedTheme = themeId;
|
|
}
|
|
void updateAllColors();
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error setting theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply theme components (CSS, images, settings)
|
|
*/
|
|
private async applyTheme(theme: CustomTheme): Promise<void> {
|
|
console.debug("[ThemeManager] Applying theme:", theme.name);
|
|
try {
|
|
// Apply custom CSS
|
|
if (theme.CustomCSS) {
|
|
console.debug("[ThemeManager] Applying custom CSS");
|
|
this.applyCustomCSS(theme.CustomCSS);
|
|
}
|
|
|
|
// Apply custom images
|
|
if (theme.CustomImages) {
|
|
console.debug("[ThemeManager] Applying custom images");
|
|
theme.CustomImages.forEach((image) => {
|
|
const imageUrl = URL.createObjectURL(image.blob);
|
|
document.documentElement.style.setProperty(
|
|
"--" + image.variableName,
|
|
`url(${imageUrl})`,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Apply theme settings
|
|
if (shouldForceThemeAppearance(theme)) {
|
|
const dark = getForcedDarkMode(theme);
|
|
console.debug("[ThemeManager] Setting dark mode:", dark);
|
|
settingsState.DarkMode = dark;
|
|
}
|
|
|
|
// Use the stored selected color if available, otherwise use the default
|
|
if (theme.selectedColor) {
|
|
console.debug(
|
|
"[ThemeManager] Restoring saved color:",
|
|
theme.selectedColor,
|
|
);
|
|
settingsState.selectedColor = theme.selectedColor;
|
|
} else if (theme.defaultColour) {
|
|
console.debug(
|
|
"[ThemeManager] Using default color:",
|
|
theme.defaultColour,
|
|
);
|
|
settingsState.selectedColor = theme.defaultColour;
|
|
}
|
|
|
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error applying theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove theme and restore original settings with view transition
|
|
*/
|
|
private async removeTheme(
|
|
theme: CustomTheme,
|
|
clearSelectedTheme: boolean = true,
|
|
): Promise<void> {
|
|
console.debug("[ThemeManager] Removing theme with transition:", theme.name);
|
|
try {
|
|
await this.applyViewTransition(async () => {
|
|
await this.removeThemeWithoutTransition(theme, clearSelectedTheme);
|
|
});
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error removing theme with transition:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove theme without applying view transition animation
|
|
*/
|
|
private async removeThemeWithoutTransition(
|
|
theme: CustomTheme,
|
|
clearSelectedTheme: boolean = true,
|
|
): Promise<void> {
|
|
console.debug("[ThemeManager] Removing theme:", theme.name);
|
|
try {
|
|
// Remove custom CSS
|
|
if (this.styleElement) {
|
|
console.debug("[ThemeManager] Removing custom CSS");
|
|
this.styleElement.remove();
|
|
this.styleElement = null;
|
|
}
|
|
|
|
// Remove custom images
|
|
if (theme.CustomImages) {
|
|
console.debug("[ThemeManager] Removing custom images");
|
|
theme.CustomImages.forEach((image) => {
|
|
const value = document.documentElement.style.getPropertyValue(
|
|
"--" + image.variableName,
|
|
);
|
|
if (value) {
|
|
URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper
|
|
}
|
|
document.documentElement.style.removeProperty(
|
|
"--" + image.variableName,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (this.currentTheme) {
|
|
// Store the current color with the theme before removing it
|
|
const selectedColor = settingsState.selectedColor;
|
|
const markUserEditedForColor =
|
|
this.currentTheme.userEdited !== true &&
|
|
this.currentTheme.installedFromStore !== false &&
|
|
selectedColor &&
|
|
this.currentTheme.defaultColour &&
|
|
selectedColor.trim().toLowerCase() !==
|
|
this.currentTheme.defaultColour.trim().toLowerCase();
|
|
await localforage.setItem(this.currentTheme.id, {
|
|
...this.currentTheme,
|
|
selectedColor,
|
|
...(markUserEditedForColor ? { userEdited: true } : {}),
|
|
});
|
|
}
|
|
|
|
// Restore original settings
|
|
if (settingsState.originalSelectedColor) {
|
|
console.debug(
|
|
"[ThemeManager] Restoring original color:",
|
|
settingsState.originalSelectedColor,
|
|
);
|
|
settingsState.selectedColor = settingsState.originalSelectedColor;
|
|
}
|
|
|
|
if (settingsState.originalDarkMode !== undefined) {
|
|
console.debug(
|
|
"[ThemeManager] Restoring original dark mode:",
|
|
settingsState.originalDarkMode,
|
|
);
|
|
settingsState.DarkMode = settingsState.originalDarkMode;
|
|
delete settingsState.originalDarkMode;
|
|
}
|
|
|
|
this.currentTheme = null;
|
|
if (clearSelectedTheme) {
|
|
settingsState.selectedTheme = "";
|
|
}
|
|
clearCustomThemeAdaptiveCssVariables();
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error removing theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply custom CSS to the document
|
|
*/
|
|
private applyCustomCSS(css: string): void {
|
|
console.debug("[ThemeManager] Applying custom CSS");
|
|
try {
|
|
if (!this.styleElement) {
|
|
this.styleElement = document.createElement("style");
|
|
this.styleElement.id = "custom-theme";
|
|
document.head.appendChild(this.styleElement);
|
|
}
|
|
this.styleElement.textContent = css;
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error applying custom CSS:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of available themes
|
|
*/
|
|
public async getAvailableThemes(): Promise<CustomTheme[]> {
|
|
console.debug("[ThemeManager] Getting available themes");
|
|
try {
|
|
const themeIds = (await localforage.getItem("customThemes")) as
|
|
| string[]
|
|
| null;
|
|
if (!themeIds) {
|
|
return [];
|
|
}
|
|
|
|
const themes = await Promise.all(
|
|
themeIds.map(async (id) => {
|
|
return (await localforage.getItem(id)) as CustomTheme;
|
|
}),
|
|
);
|
|
|
|
return themes.filter((theme) => theme !== null);
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error getting available themes:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save or update a theme
|
|
*/
|
|
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
|
|
console.debug("[ThemeManager] Saving theme:", theme.name);
|
|
try {
|
|
const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
|
|
let toSave = theme;
|
|
if (existing?.userEdited === true && theme.userEdited !== false) {
|
|
toSave = { ...theme, userEdited: true };
|
|
}
|
|
|
|
await localforage.setItem(toSave.id, toSave);
|
|
const themeIds = (await localforage.getItem("customThemes")) as
|
|
| string[]
|
|
| null;
|
|
|
|
if (themeIds) {
|
|
if (!themeIds.includes(toSave.id)) {
|
|
themeIds.push(toSave.id);
|
|
await localforage.setItem("customThemes", themeIds);
|
|
}
|
|
} else {
|
|
await localforage.setItem("customThemes", [toSave.id]);
|
|
}
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error saving theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a theme
|
|
*/
|
|
public async deleteTheme(themeId: string): Promise<void> {
|
|
console.debug("[ThemeManager] Deleting theme:", themeId);
|
|
try {
|
|
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
|
if (theme) {
|
|
if (this.currentTheme?.id === themeId) {
|
|
await this.removeTheme(theme);
|
|
}
|
|
await localforage.removeItem(themeId);
|
|
|
|
const themeIds = (await localforage.getItem("customThemes")) as
|
|
| string[]
|
|
| null;
|
|
if (themeIds) {
|
|
const updatedThemeIds = themeIds.filter((id) => id !== themeId);
|
|
await localforage.setItem("customThemes", updatedThemeIds);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error deleting theme:", error);
|
|
}
|
|
}
|
|
|
|
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
|
|
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
|
|
|
/**
|
|
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
|
|
*/
|
|
private async fetchFromUrl(url: string): Promise<any> {
|
|
const result = (await browser.runtime.sendMessage({
|
|
type: 'fetchFromUrl',
|
|
url,
|
|
})) as { data?: unknown; error?: string };
|
|
if (result?.error) throw new Error(result.error);
|
|
return result?.data;
|
|
}
|
|
|
|
/**
|
|
* Download and install a theme from the store.
|
|
* Uses API first (increments download_count), falls back to GitHub if unreachable.
|
|
*/
|
|
public async downloadTheme(themeContent: {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
coverImage?: string;
|
|
theme_json_url?: string;
|
|
updated_at?: number;
|
|
}): Promise<void> {
|
|
try {
|
|
await this.downloadAndInstallStoreTheme(themeContent);
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error downloading theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch theme.json from the store and install (throws on failure).
|
|
*/
|
|
private async downloadAndInstallStoreTheme(themeContent: {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
coverImage?: string;
|
|
theme_json_url?: string;
|
|
updated_at?: number;
|
|
}): Promise<void> {
|
|
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
|
if (!themeContent.id) {
|
|
throw new Error("Missing theme id");
|
|
}
|
|
|
|
let themeData: ThemeContent;
|
|
|
|
try {
|
|
const downloadData = (await this.fetchFromUrl(
|
|
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
|
|
)) as { success?: boolean; data?: { theme_json_url: string } };
|
|
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
|
|
throw new Error("Failed to get theme download URL");
|
|
}
|
|
themeData = (await this.fetchFromUrl(
|
|
downloadData.data.theme_json_url,
|
|
)) as ThemeContent;
|
|
} catch (apiError) {
|
|
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
|
|
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
|
|
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
|
|
}
|
|
|
|
await this.installTheme(themeData, {
|
|
fromStore: true,
|
|
serverUpdatedAtSec: themeContent.updated_at,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Install a theme from theme data
|
|
*/
|
|
public async installTheme(
|
|
themeData: ThemeContent,
|
|
meta?: InstallThemeMeta,
|
|
): Promise<void> {
|
|
console.debug("[ThemeManager] Installing theme:", themeData.name);
|
|
try {
|
|
// Validate required fields
|
|
if (!themeData.id || !themeData.name) {
|
|
throw new Error("Theme is missing required fields (id or name)");
|
|
}
|
|
|
|
const fromStore = meta?.fromStore ?? false;
|
|
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
|
|
|
|
// Handle cover image (optional)
|
|
let coverImageBlob = null;
|
|
if (themeData.coverImage) {
|
|
try {
|
|
const strippedCoverImage = this.stripBase64Prefix(
|
|
themeData.coverImage,
|
|
);
|
|
coverImageBlob = this.base64ToBlob(strippedCoverImage);
|
|
} catch (e) {
|
|
console.warn("[ThemeManager] Failed to process cover image:", e);
|
|
// Continue without cover image
|
|
}
|
|
}
|
|
|
|
// Handle images (optional)
|
|
const images =
|
|
themeData.images
|
|
?.map((image) => {
|
|
try {
|
|
if (!image.id || !image.variableName || !image.data) {
|
|
console.warn("[ThemeManager] Skipping invalid image:", image);
|
|
return null;
|
|
}
|
|
return {
|
|
...image,
|
|
blob: this.base64ToBlob(this.stripBase64Prefix(image.data)),
|
|
};
|
|
} catch (e) {
|
|
console.warn("[ThemeManager] Failed to process image:", e);
|
|
return null;
|
|
}
|
|
})
|
|
.filter((img) => img !== null) ?? [];
|
|
|
|
const theme: LoadedCustomTheme = {
|
|
id: themeData.id,
|
|
name: themeData.name,
|
|
description: themeData.description || "",
|
|
webURL: themeData.id,
|
|
coverImage: coverImageBlob,
|
|
CustomImages: images,
|
|
CustomCSS: themeData.CustomCSS || "",
|
|
defaultColour: themeData.defaultColour || "rgba(0, 123, 255, 1)",
|
|
CanChangeColour: themeData.CanChangeColour ?? true,
|
|
allowBackgrounds: true,
|
|
isEditable: false,
|
|
hideThemeName: themeData.hideThemeName ?? false,
|
|
forceDark: themeData.forceDark,
|
|
installedFromStore: fromStore,
|
|
userEdited: fromStore ? false : undefined,
|
|
storeSyncedAtSec:
|
|
fromStore && serverUpdatedAtSec != null
|
|
? serverUpdatedAtSec
|
|
: undefined,
|
|
forceTheme:
|
|
themeData.forceTheme !== undefined
|
|
? themeData.forceTheme
|
|
: themeData.forceDark !== undefined
|
|
? true
|
|
: undefined,
|
|
adaptiveCssVariables: themeData.adaptiveCssVariables,
|
|
};
|
|
|
|
await this.saveTheme(theme);
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error installing theme:", error);
|
|
throw error; // Re-throw to handle in UI
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
|
|
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
|
|
*/
|
|
public async checkStoreThemeUpdates(): Promise<void> {
|
|
if (this.storeUpdateCheckRunning) return;
|
|
this.storeUpdateCheckRunning = true;
|
|
try {
|
|
const token = await cloudAuth.getStoredToken();
|
|
const res = (await browser.runtime.sendMessage({
|
|
type: "fetchThemes",
|
|
token: token ?? undefined,
|
|
})) as {
|
|
success?: boolean;
|
|
data?: { themes?: Array<{ id: string; updated_at?: number }> };
|
|
};
|
|
|
|
if (!res?.success || !res.data?.themes?.length) return;
|
|
|
|
const serverById = new Map<string, number>();
|
|
for (const t of res.data.themes) {
|
|
if (
|
|
typeof t.id === "string" &&
|
|
typeof t.updated_at === "number"
|
|
) {
|
|
serverById.set(t.id, t.updated_at);
|
|
}
|
|
}
|
|
if (serverById.size === 0) return;
|
|
|
|
const themeIds = (await localforage.getItem("customThemes")) as
|
|
| string[]
|
|
| null;
|
|
if (!themeIds?.length) return;
|
|
|
|
for (const id of themeIds) {
|
|
const theme = (await localforage.getItem(id)) as CustomTheme | null;
|
|
if (!theme || theme.userEdited === true) {
|
|
continue;
|
|
}
|
|
|
|
// File imports explicitly set installedFromStore: false — never auto-sync.
|
|
if (theme.installedFromStore === false) {
|
|
continue;
|
|
}
|
|
|
|
const serverUpdated = serverById.get(id);
|
|
if (serverUpdated == null) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
theme.installedFromStore === true &&
|
|
theme.storeSyncedAtSec != null
|
|
) {
|
|
if (serverUpdated <= theme.storeSyncedAtSec) {
|
|
continue;
|
|
}
|
|
}
|
|
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
|
|
// unset) or incomplete rows — still listed on the server, so sync to latest once.
|
|
|
|
const wasSelected = settingsState.selectedTheme === id;
|
|
try {
|
|
if (wasSelected) {
|
|
await this.disableTheme();
|
|
}
|
|
await this.downloadAndInstallStoreTheme({
|
|
id: theme.id,
|
|
name: theme.name,
|
|
updated_at: serverUpdated,
|
|
});
|
|
console.log(
|
|
"[ThemeManager] Theme auto-updated from store:",
|
|
theme.name,
|
|
);
|
|
if (wasSelected) {
|
|
await this.setTheme(id, false);
|
|
}
|
|
themeUpdates.triggerUpdate();
|
|
} catch (err) {
|
|
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
|
|
if (wasSelected) {
|
|
try {
|
|
await this.setTheme(id, false);
|
|
} catch (restoreErr) {
|
|
console.error(
|
|
"[ThemeManager] Failed to restore theme after update error:",
|
|
restoreErr,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
|
|
} finally {
|
|
this.storeUpdateCheckRunning = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Share a theme by exporting it
|
|
*/
|
|
public async shareTheme(themeId: string): Promise<void> {
|
|
console.debug("[ThemeManager] Sharing theme:", themeId);
|
|
try {
|
|
const theme = (await localforage.getItem(themeId)) as LoadedCustomTheme;
|
|
if (!theme) {
|
|
console.error("[ThemeManager] Theme not found");
|
|
return;
|
|
}
|
|
|
|
// Extract only the fields we want to share
|
|
const {
|
|
CustomImages = [],
|
|
coverImage,
|
|
webURL,
|
|
isEditable,
|
|
selectedColor,
|
|
allowBackgrounds,
|
|
installedFromStore,
|
|
storeSyncedAtSec,
|
|
userEdited,
|
|
...themeBasics
|
|
} = theme;
|
|
|
|
// Convert images to base64
|
|
const finalImages = await Promise.all(
|
|
CustomImages.map(async (image) => ({
|
|
id: image.id,
|
|
variableName: image.variableName,
|
|
data: await this.blobToBase64(image.blob),
|
|
})),
|
|
);
|
|
|
|
// Convert cover image to base64
|
|
const coverImageBase64 = coverImage
|
|
? await this.blobToBase64(coverImage)
|
|
: null;
|
|
|
|
// Create shareable theme data with only necessary fields
|
|
const shareableTheme = {
|
|
...themeBasics,
|
|
images: finalImages,
|
|
coverImage: coverImageBase64,
|
|
};
|
|
|
|
// Save theme file
|
|
this.saveThemeFile(shareableTheme, theme.name || "Unnamed_Theme");
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error sharing theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preview a theme without applying it
|
|
*/
|
|
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
|
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
|
try {
|
|
const { CustomCSS, CustomImages, defaultColour } = theme;
|
|
|
|
// Store original settings only if this is a new theme
|
|
if (!theme.webURL) {
|
|
if (this.originalPreviewColor === null) {
|
|
this.originalPreviewColor = settingsState.selectedColor;
|
|
localStorage.setItem(
|
|
"originalPreviewColor",
|
|
settingsState.selectedColor,
|
|
);
|
|
}
|
|
if (this.originalPreviewTheme === null) {
|
|
this.originalPreviewTheme = settingsState.DarkMode;
|
|
}
|
|
}
|
|
|
|
// Apply custom CSS
|
|
if (CustomCSS) {
|
|
this.applyPreviewCSS(CustomCSS);
|
|
}
|
|
|
|
// Apply custom images
|
|
const newImageVariableNames = CustomImages.map(
|
|
(image) => image.variableName,
|
|
);
|
|
|
|
// Remove old preview images
|
|
this.previousImageVariableNames.forEach((variableName) => {
|
|
if (!newImageVariableNames.includes(variableName)) {
|
|
this.removeImageFromDocument(variableName);
|
|
}
|
|
});
|
|
|
|
// Apply new images
|
|
CustomImages.forEach((image) => {
|
|
const imageUrl = URL.createObjectURL(image.blob);
|
|
document.documentElement.style.setProperty(
|
|
`--${image.variableName}`,
|
|
`url(${imageUrl})`,
|
|
);
|
|
});
|
|
|
|
// Update previousImageVariableNames
|
|
this.previousImageVariableNames = newImageVariableNames;
|
|
|
|
// Apply theme settings
|
|
if (shouldForceThemeAppearance(theme)) {
|
|
settingsState.DarkMode = getForcedDarkMode(theme);
|
|
}
|
|
|
|
if (defaultColour) {
|
|
settingsState.selectedColor = defaultColour;
|
|
}
|
|
|
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
|
void updateAllColors();
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error previewing theme:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the preview of a theme in real-time (for theme creator)
|
|
*/
|
|
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
|
|
console.debug("[ThemeManager] Updating theme preview");
|
|
try {
|
|
// Only store original settings if this is a new theme (not editing)
|
|
// We can tell it's a new theme if it has no webURL (which is set when a theme is saved/loaded)
|
|
if (!theme.webURL) {
|
|
if (this.originalPreviewColor === null) {
|
|
this.originalPreviewColor = settingsState.selectedColor;
|
|
}
|
|
if (this.originalPreviewTheme === null) {
|
|
this.originalPreviewTheme = settingsState.DarkMode;
|
|
}
|
|
}
|
|
|
|
// Apply CSS if changed
|
|
if (theme.CustomCSS !== undefined) {
|
|
this.applyPreviewCSS(theme.CustomCSS);
|
|
}
|
|
|
|
// Handle images if present
|
|
if (theme.CustomImages) {
|
|
const newImageVariableNames = theme.CustomImages.map(
|
|
(image) => image.variableName,
|
|
);
|
|
|
|
// Remove old preview images that are no longer present
|
|
this.previousImageVariableNames.forEach((variableName) => {
|
|
if (!newImageVariableNames.includes(variableName)) {
|
|
this.removeImageFromDocument(variableName);
|
|
// Clean up cached URL
|
|
this.imageUrlCache.delete(variableName);
|
|
}
|
|
});
|
|
|
|
// Apply or update images
|
|
theme.CustomImages.forEach((image) => {
|
|
const existingUrl = this.imageUrlCache.get(image.variableName);
|
|
if (!existingUrl) {
|
|
// Only create new URL if one doesn't exist
|
|
const imageUrl = URL.createObjectURL(image.blob);
|
|
this.imageUrlCache.set(image.variableName, imageUrl);
|
|
document.documentElement.style.setProperty(
|
|
`--${image.variableName}`,
|
|
`url(${imageUrl})`,
|
|
);
|
|
} else {
|
|
// Reuse existing URL
|
|
document.documentElement.style.setProperty(
|
|
`--${image.variableName}`,
|
|
`url(${existingUrl})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
this.previousImageVariableNames = newImageVariableNames;
|
|
}
|
|
|
|
// Always apply dark mode setting when theme forces appearance
|
|
if (shouldForceThemeAppearance(theme as CustomTheme)) {
|
|
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
|
|
}
|
|
|
|
// Only apply color if this is a new theme
|
|
if (!theme.webURL && theme.defaultColour) {
|
|
settingsState.selectedColor = theme.defaultColour;
|
|
}
|
|
|
|
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
|
void updateAllColors();
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error updating theme preview:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the preview of a theme (debounced)
|
|
* @param theme - The theme to update the preview of
|
|
*/
|
|
public updatePreviewDebounced = debounce(
|
|
(theme: Partial<LoadedCustomTheme>): void => {
|
|
this.updatePreview(theme);
|
|
},
|
|
2,
|
|
);
|
|
|
|
/**
|
|
* Clear theme preview
|
|
*/
|
|
public clearPreview(): void {
|
|
console.debug("[ThemeManager] Clearing theme preview");
|
|
try {
|
|
// Remove preview images and revoke URLs
|
|
this.previousImageVariableNames.forEach((variableName) => {
|
|
this.removeImageFromDocument(variableName);
|
|
});
|
|
// Clear all cached URLs
|
|
this.imageUrlCache.forEach((url) => URL.revokeObjectURL(url));
|
|
this.imageUrlCache.clear();
|
|
this.previousImageVariableNames = [];
|
|
|
|
// Remove preview CSS
|
|
if (this.previewStyleElement) {
|
|
this.previewStyleElement.remove();
|
|
this.previewStyleElement = null;
|
|
}
|
|
|
|
clearCustomThemeAdaptiveCssVariables();
|
|
|
|
// Restore original settings
|
|
const storedColor = localStorage.getItem("originalPreviewColor");
|
|
|
|
if (storedColor) {
|
|
settingsState.selectedColor = storedColor;
|
|
localStorage.removeItem("originalPreviewColor");
|
|
} else if (this.originalPreviewColor !== null) {
|
|
console.debug(
|
|
"[ThemeManager] Restoring color from memory:",
|
|
this.originalPreviewColor,
|
|
);
|
|
settingsState.selectedColor = this.originalPreviewColor;
|
|
console.debug(
|
|
"[ThemeManager] Color after restore:",
|
|
settingsState.selectedColor,
|
|
);
|
|
} else {
|
|
console.debug("[ThemeManager] No color to restore found");
|
|
}
|
|
this.originalPreviewColor = null;
|
|
|
|
if (this.originalPreviewTheme !== null) {
|
|
console.debug(
|
|
"[ThemeManager] Restoring dark mode:",
|
|
this.originalPreviewTheme,
|
|
);
|
|
settingsState.DarkMode = this.originalPreviewTheme;
|
|
this.originalPreviewTheme = null;
|
|
}
|
|
|
|
void updateAllColors();
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error clearing preview:", error);
|
|
}
|
|
}
|
|
|
|
// Utility methods
|
|
private stripBase64Prefix(base64String: string): string {
|
|
if (!base64String) return "";
|
|
|
|
const prefixRegex = /^data:[^;]+;base64,/;
|
|
try {
|
|
return prefixRegex.test(base64String)
|
|
? base64String.replace(prefixRegex, "")
|
|
: base64String;
|
|
} catch (err) {
|
|
console.error("[ThemeManager] Error stripping base64 prefix:", err);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private base64ToBlob(base64: string): Blob {
|
|
try {
|
|
const byteString = atob(base64);
|
|
const ab = new ArrayBuffer(byteString.length);
|
|
const ia = new Uint8Array(ab);
|
|
|
|
for (let i = 0; i < byteString.length; i++) {
|
|
ia[i] = byteString.charCodeAt(i);
|
|
}
|
|
|
|
return new Blob([ab], { type: "image/png" });
|
|
} catch (err) {
|
|
console.error("[ThemeManager] Error converting base64 to blob:", err);
|
|
return new Blob();
|
|
}
|
|
}
|
|
|
|
private async blobToBase64(blob: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const base64String = reader.result as string;
|
|
const base64Data = base64String.split(",")[1];
|
|
resolve(base64Data);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
private saveThemeFile(data: object, fileName: string): void {
|
|
try {
|
|
const fileData = JSON.stringify(data, null, 2);
|
|
const blob = new Blob([fileData], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${fileName}.theme.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error("[ThemeManager] Error saving theme file:", err);
|
|
}
|
|
}
|
|
|
|
private removeImageFromDocument(variableName: string): void {
|
|
try {
|
|
const value = document.documentElement.style.getPropertyValue(
|
|
"--" + variableName,
|
|
);
|
|
if (value) {
|
|
const url = this.imageUrlCache.get(variableName);
|
|
if (url) {
|
|
URL.revokeObjectURL(url);
|
|
this.imageUrlCache.delete(variableName);
|
|
}
|
|
}
|
|
document.documentElement.style.removeProperty("--" + variableName);
|
|
} catch (err) {
|
|
console.error("[ThemeManager] Error removing image from document:", err);
|
|
}
|
|
}
|
|
|
|
private applyPreviewCSS(css: string): void {
|
|
console.debug("[ThemeManager] Applying preview CSS");
|
|
try {
|
|
if (!this.previewStyleElement) {
|
|
this.previewStyleElement = document.createElement("style");
|
|
this.previewStyleElement.id = "custom-theme-preview";
|
|
document.head.appendChild(this.previewStyleElement);
|
|
}
|
|
this.previewStyleElement.textContent = css;
|
|
} catch (error) {
|
|
console.error("[ThemeManager] Error applying preview CSS:", error);
|
|
}
|
|
}
|
|
}
|