mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
@@ -8,6 +8,8 @@ import {
|
||||
} 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,
|
||||
@@ -24,6 +26,13 @@ type ThemeContent = {
|
||||
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
|
||||
@@ -39,6 +48,7 @@ export class ThemeManager {
|
||||
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...");
|
||||
@@ -187,6 +197,8 @@ export class ThemeManager {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error during initialization:", error);
|
||||
} finally {
|
||||
void this.checkStoreThemeUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +373,18 @@ export class ThemeManager {
|
||||
|
||||
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: settingsState.selectedColor,
|
||||
selectedColor,
|
||||
...(markUserEditedForColor ? { userEdited: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -444,18 +465,24 @@ export class ThemeManager {
|
||||
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug("[ThemeManager] Saving theme:", theme.name);
|
||||
try {
|
||||
await localforage.setItem(theme.id, theme);
|
||||
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(theme.id)) {
|
||||
themeIds.push(theme.id);
|
||||
if (!themeIds.includes(toSave.id)) {
|
||||
themeIds.push(toSave.id);
|
||||
await localforage.setItem("customThemes", themeIds);
|
||||
}
|
||||
} else {
|
||||
await localforage.setItem("customThemes", [theme.id]);
|
||||
await localforage.setItem("customThemes", [toSave.id]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error saving theme:", error);
|
||||
@@ -513,39 +540,62 @@ export class ThemeManager {
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
theme_json_url?: string;
|
||||
updated_at?: number;
|
||||
}): Promise<void> {
|
||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||
try {
|
||||
if (!themeContent.id) return;
|
||||
|
||||
let themeData: ThemeContent;
|
||||
|
||||
try {
|
||||
// Try API first (increments download_count)
|
||||
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) {
|
||||
// Fallback to GitHub if API is unreachable
|
||||
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);
|
||||
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): Promise<void> {
|
||||
public async installTheme(
|
||||
themeData: ThemeContent,
|
||||
meta?: InstallThemeMeta,
|
||||
): Promise<void> {
|
||||
console.debug("[ThemeManager] Installing theme:", themeData.name);
|
||||
try {
|
||||
// Validate required fields
|
||||
@@ -553,6 +603,9 @@ export class ThemeManager {
|
||||
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) {
|
||||
@@ -587,7 +640,6 @@ export class ThemeManager {
|
||||
})
|
||||
.filter((img) => img !== null) ?? [];
|
||||
|
||||
// Create theme with defaults for optional fields
|
||||
const theme: LoadedCustomTheme = {
|
||||
id: themeData.id,
|
||||
name: themeData.name,
|
||||
@@ -602,6 +654,12 @@ export class ThemeManager {
|
||||
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
|
||||
@@ -618,6 +676,107 @@ export class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -638,6 +797,9 @@ export class ThemeManager {
|
||||
isEditable,
|
||||
selectedColor,
|
||||
allowBackgrounds,
|
||||
installedFromStore,
|
||||
storeSyncedAtSec,
|
||||
userEdited,
|
||||
...themeBasics
|
||||
} = theme;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user