From f2fa9c39a96271da842695d954ff788ff0fe4d18 Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:13:27 +0930 Subject: [PATCH] Merge pull request #418 from StroepWafel/theme-updates Theme updates --- .../components/themes/ThemeSelector.svelte | 2 +- src/interface/pages/themeCreator.svelte | 1 + src/interface/types/Theme.ts | 2 + src/plugins/built-in/globalSearch/lazy.ts | 1 - .../built-in/globalSearch/src/core/index.ts | 1 - src/plugins/built-in/themes/theme-manager.ts | 220 +++++++++++++++--- src/seqta/utils/listeners/MessageListener.ts | 5 +- src/types/CustomThemes.ts | 6 + 8 files changed, 205 insertions(+), 33 deletions(-) diff --git a/src/interface/components/themes/ThemeSelector.svelte b/src/interface/components/themes/ThemeSelector.svelte index 80be9e8d..51a2cf16 100644 --- a/src/interface/components/themes/ThemeSelector.svelte +++ b/src/interface/components/themes/ThemeSelector.svelte @@ -85,7 +85,7 @@ try { const result = JSON.parse(event.target?.result as string); tempTheme = result; - await themeManager.installTheme(result); + await themeManager.installTheme(result, { fromStore: false }); await fetchThemes(); } catch (error) { console.error('Error parsing file:', error); diff --git a/src/interface/pages/themeCreator.svelte b/src/interface/pages/themeCreator.svelte index d8da6b95..e24a7fea 100644 --- a/src/interface/pages/themeCreator.svelte +++ b/src/interface/pages/themeCreator.svelte @@ -125,6 +125,7 @@ blob: image.blob })) themeClone.coverImage = theme.coverImage + themeClone.userEdited = true if (shouldForceThemeAppearance(themeClone)) { themeClone.forceTheme = true; diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index 28fb7918..2179156e 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -8,4 +8,6 @@ export type Theme = { is_favorited?: boolean; favorite_count?: number; download_count?: number; + /** Unix seconds — last server update (GET /api/themes). */ + updated_at?: number; }; diff --git a/src/plugins/built-in/globalSearch/lazy.ts b/src/plugins/built-in/globalSearch/lazy.ts index 7e50f7d5..a8ae19eb 100644 --- a/src/plugins/built-in/globalSearch/lazy.ts +++ b/src/plugins/built-in/globalSearch/lazy.ts @@ -120,7 +120,6 @@ export default defineLazyPlugin({ settings, disableToggle: true, defaultEnabled: false, - beta: true, styles: styles, // Lazy loader - only imports the heavy plugin when actually needed diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index 5890cd9c..65f9f628 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -145,7 +145,6 @@ const globalSearchPlugin: Plugin = { settings: settingsInstance.settings, disableToggle: true, defaultEnabled: false, - beta: true, styles: styles, run: async (api) => { diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 7e3b2192..2260e8df 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -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 = 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 { 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 { - 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 { + 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 { + public async installTheme( + themeData: ThemeContent, + meta?: InstallThemeMeta, + ): Promise { 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 { + 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(); + 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; diff --git a/src/seqta/utils/listeners/MessageListener.ts b/src/seqta/utils/listeners/MessageListener.ts index b0a32ff7..cf9cb82e 100644 --- a/src/seqta/utils/listeners/MessageListener.ts +++ b/src/seqta/utils/listeners/MessageListener.ts @@ -34,7 +34,10 @@ export class MessageHandler { case "UpdateThemePreview": if (request?.save == true) { const save = async () => { - await themeManager.saveTheme(request.body); + await themeManager.saveTheme({ + ...request.body, + userEdited: true, + }); if (request.body.enableTheme) { await themeManager.setTheme(request.body.id); } diff --git a/src/types/CustomThemes.ts b/src/types/CustomThemes.ts index 6ff7def4..6170c167 100644 --- a/src/types/CustomThemes.ts +++ b/src/types/CustomThemes.ts @@ -18,6 +18,12 @@ export type CustomTheme = { */ forceTheme?: boolean; forceDark?: boolean; + /** True if installed from the BetterSEQTA theme store (not file import). */ + installedFromStore?: boolean; + /** Server `updated_at` (Unix seconds) when this copy was installed or last auto-updated. */ + storeSyncedAtSec?: number; + /** User saved edits in theme creator or popup; blocks store auto-update. */ + userEdited?: boolean; /** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */ adaptiveCssVariables?: string[]; };