Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

745 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { THEME_CONFIG } from './data/themeConfig';
import BroadcastBus, { ACTION_THEME_CHANGED } from './BroadcastBus';
import { now as timeNow } from './TimeService';
import { debouncedSetItem } from './debouncedPersist';
export type ThemeId = string;
export type ThemeComponentCode = string;
export type ThemeMeta = {
id: ThemeId;
title: string;
author: string;
description: string;
price: number | null;
version: string | null;
assemblyId: string | null;
/** Preview file names (relative to /themes/{id}/preview/) */
previews: string[];
/** Backward-compat alias of `previews` */
builtInPreviews: string[];
subResources: Array<{ resourceCode: ThemeComponentCode; localId: string }>;
/** Extra fields from manifest (optional) */
type?: 'full' | 'font' | 'aod' | string;
capabilities?: string[];
iconPackageNames?: string[];
statusbarIcons?: string[];
statusbarDynamic?: {
/** 例:5 -> wifi_0..4.png */
wifiLevels?: number;
/** 例:6 -> signal_0..5.png */
signalLevels?: number;
/** 电池帧数量(用于 battery_sprite.png */
batteryFrames?: number;
/** 例如 "battery_sprite.png" */
batterySprite?: string;
/** 单帧边长(像素),例如 40 */
batteryFrameSide?: number;
/**
* 主题烤色的充电变体 sprite(RGBAbody 是主题选定的绿/teal/蓝、bolt 已在
* 帧内)。前端在充电时优先用这张图直接渲染,丢回退到 base sprite + 系统色 tint。
*/
batteryChargeFrames?: number;
batteryChargeSprite?: string;
batteryChargeFrameSide?: number;
/** 主题烤色的省电变体 sprite(RGBA,body 是主题黄/橙)。 */
batteryPowerSaveFrames?: number;
batteryPowerSaveSprite?: string;
batteryPowerSaveFrameSide?: number;
/** 主题烤色的"省电+充电"变体 spriteRGBA,黄 body + bolt)。 */
batteryPowerSaveChargeFrames?: number;
batteryPowerSaveChargeSprite?: string;
batteryPowerSaveChargeFrameSide?: number;
/**
* 充电闪电单帧 overlay(仅 alpha,由前端 tint)。仅在系统兜底渲染路径
* (主题没有提供 `_charge` 变体)下使用——主题 RGBA 路径的 bolt 已经烤进
* 帧里,无需再叠。
*/
batteryBoltOverlay?: string;
/** 可用的数据类型图标集合(如 ["4g","5g","lte"] */
dataTypes?: string[];
};
extracted?: any;
/** Per-component asset index: component -> { assetBaseName -> relativePath } */
componentAssets?: Record<string, Record<string, string>>;
};
type ThemeManifest = {
version: number;
generatedAt?: string | null;
defaultThemeId?: ThemeId | null;
themes: ThemeMeta[];
fonts?: ThemeMeta[];
aod?: ThemeMeta[];
};
type ThemeComponentKey = 'icons' | 'statusbar' | 'shade';
type ActiveThemeConfigV2 = {
version: 2;
themeId: ThemeId;
appliedAt: number;
components: Record<ThemeComponentKey, ThemeId>;
};
function safeJsonParse<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function uniq<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}
function isThemeMetaLike(x: any): x is ThemeMeta {
return x && typeof x === 'object' && typeof x.id === 'string';
}
function normalizeThemeMeta(raw: any): ThemeMeta | null {
if (!raw || typeof raw !== 'object') return null;
const id = typeof raw.id === 'string' ? raw.id : null;
if (!id) return null;
const previews = Array.isArray(raw.previews) ? raw.previews.filter((x: any) => typeof x === 'string') : [];
const builtInPreviews = Array.isArray(raw.builtInPreviews)
? raw.builtInPreviews.filter((x: any) => typeof x === 'string')
: previews;
const subResources = Array.isArray(raw.subResources)
? raw.subResources
.map((sr: any) => ({
resourceCode: String(sr?.resourceCode || ''),
localId: String(sr?.localId || ''),
}))
.filter((sr: any) => sr.resourceCode && sr.localId)
: [];
const dynRaw = raw.statusbarDynamic && typeof raw.statusbarDynamic === 'object' ? raw.statusbarDynamic : null;
const statusbarDynamic =
dynRaw && typeof dynRaw === 'object'
? {
wifiLevels: typeof dynRaw.wifiLevels === 'number' ? dynRaw.wifiLevels : undefined,
signalLevels: typeof dynRaw.signalLevels === 'number' ? dynRaw.signalLevels : undefined,
batteryFrames: typeof dynRaw.batteryFrames === 'number' ? dynRaw.batteryFrames : undefined,
batterySprite: typeof dynRaw.batterySprite === 'string' ? dynRaw.batterySprite : undefined,
batteryFrameSide: typeof dynRaw.batteryFrameSide === 'number' ? dynRaw.batteryFrameSide : undefined,
batteryChargeFrames:
typeof dynRaw.batteryChargeFrames === 'number' ? dynRaw.batteryChargeFrames : undefined,
batteryChargeSprite:
typeof dynRaw.batteryChargeSprite === 'string' ? dynRaw.batteryChargeSprite : undefined,
batteryChargeFrameSide:
typeof dynRaw.batteryChargeFrameSide === 'number' ? dynRaw.batteryChargeFrameSide : undefined,
batteryPowerSaveFrames:
typeof dynRaw.batteryPowerSaveFrames === 'number' ? dynRaw.batteryPowerSaveFrames : undefined,
batteryPowerSaveSprite:
typeof dynRaw.batteryPowerSaveSprite === 'string' ? dynRaw.batteryPowerSaveSprite : undefined,
batteryPowerSaveFrameSide:
typeof dynRaw.batteryPowerSaveFrameSide === 'number' ? dynRaw.batteryPowerSaveFrameSide : undefined,
batteryPowerSaveChargeFrames:
typeof dynRaw.batteryPowerSaveChargeFrames === 'number' ? dynRaw.batteryPowerSaveChargeFrames : undefined,
batteryPowerSaveChargeSprite:
typeof dynRaw.batteryPowerSaveChargeSprite === 'string' ? dynRaw.batteryPowerSaveChargeSprite : undefined,
batteryPowerSaveChargeFrameSide:
typeof dynRaw.batteryPowerSaveChargeFrameSide === 'number' ? dynRaw.batteryPowerSaveChargeFrameSide : undefined,
batteryBoltOverlay:
typeof dynRaw.batteryBoltOverlay === 'string' ? dynRaw.batteryBoltOverlay : undefined,
dataTypes: Array.isArray(dynRaw.dataTypes) ? dynRaw.dataTypes.filter((x: any) => typeof x === 'string') : undefined,
}
: undefined;
return {
id,
title: typeof raw.title === 'string' ? raw.title : id,
author: typeof raw.author === 'string' ? raw.author : '',
description: typeof raw.description === 'string' ? raw.description : '',
price: typeof raw.price === 'number' ? raw.price : null,
version: typeof raw.version === 'string' ? raw.version : null,
assemblyId: typeof raw.assemblyId === 'string' ? raw.assemblyId : null,
previews,
builtInPreviews,
subResources,
type: raw.type,
capabilities: Array.isArray(raw.capabilities) ? raw.capabilities.filter((x: any) => typeof x === 'string') : undefined,
iconPackageNames: Array.isArray(raw.iconPackageNames)
? raw.iconPackageNames.filter((x: any) => typeof x === 'string')
: undefined,
statusbarIcons: Array.isArray(raw.statusbarIcons)
? raw.statusbarIcons.filter((x: any) => typeof x === 'string')
: undefined,
statusbarDynamic,
extracted: raw.extracted,
componentAssets: raw.componentAssets && typeof raw.componentAssets === 'object' ? raw.componentAssets : undefined,
};
}
function normalizeManifest(raw: any): ThemeManifest {
const themesRaw = Array.isArray(raw?.themes) ? raw.themes : [];
const fontsRaw = Array.isArray(raw?.fonts) ? raw.fonts : [];
const aodRaw = Array.isArray(raw?.aod) ? raw.aod : [];
const themes = themesRaw.map(normalizeThemeMeta).filter(Boolean) as ThemeMeta[];
const fonts = fontsRaw.map(normalizeThemeMeta).filter(Boolean) as ThemeMeta[];
const aod = aodRaw.map(normalizeThemeMeta).filter(Boolean) as ThemeMeta[];
return {
version: typeof raw?.version === 'number' ? raw.version : 1,
generatedAt: typeof raw?.generatedAt === 'string' ? raw.generatedAt : null,
defaultThemeId: typeof raw?.defaultThemeId === 'string' ? raw.defaultThemeId : null,
themes,
fonts,
aod,
};
}
function buildActiveConfig(themeId: ThemeId, components?: Partial<Record<ThemeComponentKey, ThemeId>>): ActiveThemeConfigV2 {
return {
version: 2,
themeId,
appliedAt: timeNow(),
components: {
icons: components?.icons ?? themeId,
statusbar: components?.statusbar ?? themeId,
shade: components?.shade ?? themeId,
},
};
}
function buildDefaultActiveConfig(themeId: ThemeId): ActiveThemeConfigV2 {
return buildActiveConfig(themeId, THEME_CONFIG.defaultComponents);
}
function normalizeActiveConfig(raw: any, fallbackThemeId: ThemeId): ActiveThemeConfigV2 {
const baseId = typeof raw?.themeId === 'string' ? raw.themeId : fallbackThemeId;
const componentsRaw = raw?.components && typeof raw.components === 'object' ? raw.components : {};
const get = (k: ThemeComponentKey) => (typeof componentsRaw?.[k] === 'string' ? componentsRaw[k] : baseId);
return {
version: 2,
themeId: baseId,
appliedAt: typeof raw?.appliedAt === 'number' ? raw.appliedAt : timeNow(),
components: {
icons: get('icons'),
statusbar: get('statusbar'),
shade: get('shade'),
},
};
}
async function fetchJson(url: string): Promise<any> {
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status} (${url})`);
const contentType = (resp.headers.get('content-type') || '').toLowerCase();
const raw = await resp.text();
try {
return JSON.parse(raw);
} catch (e: any) {
const snippet = raw.slice(0, 140).replace(/\s+/g, ' ').trim();
const ctInfo = contentType ? `content-type=${contentType}` : 'content-type=<missing>';
throw new SyntaxError(`Invalid JSON (${ctInfo}) from ${url}: ${snippet}`);
}
}
function isLikelyImageResponse(resp: Response): boolean {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
// If server doesn't provide content-type, assume OK (best-effort).
if (!ct) return true;
if (ct.includes('text/html')) return false;
if (ct.startsWith('image/')) return true;
return true;
}
async function urlExists(url: string): Promise<boolean> {
try {
const head = await fetch(url, { method: 'HEAD', cache: 'force-cache' });
if (head.ok || head.status === 304) return isLikelyImageResponse(head);
if (head.status !== 405) return false;
// Some servers might not support HEAD; fall back to GET.
const get = await fetch(url, { method: 'GET', cache: 'force-cache' });
if (!(get.ok || get.status === 304)) return false;
return isLikelyImageResponse(get);
} catch {
return false;
}
}
class ThemeServiceImpl {
private listeners = new Set<() => void>();
private manifest: ThemeManifest | null = null;
private manifestPromise: Promise<ThemeManifest> | null = null;
private active: ActiveThemeConfigV2 | null = null;
private currentTheme: ThemeMeta | null = null;
// Per-asset caches (existence checked via HEAD/GET)
private appAssetCache = new Map<string, string>(); // key: `${themeId}|${component}|${assetName}`
private appAssetMiss = new Set<string>();
private appAssetInFlight = new Map<string, Promise<string | null>>();
onThemeChanged(cb: () => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private emitChange() {
for (const cb of Array.from(this.listeners)) cb();
try {
BroadcastBus.sendBroadcast({
action: ACTION_THEME_CHANGED,
extras: {
themeId: this.active?.themeId ?? null,
components: this.active?.components ? { ...this.active.components } : null,
},
});
} catch (e) {
console.warn('[ThemeService] Failed to send theme changed broadcast', e);
}
}
isReady(): boolean {
return !!this.manifest && !!this.active;
}
getManifest(): ThemeManifest | null {
return this.manifest;
}
getCurrentTheme(): ThemeMeta | null {
return this.currentTheme;
}
getCurrentThemeId(): ThemeId | null {
return this.active?.themeId ?? null;
}
getActiveComponentThemeId(component: ThemeComponentKey): ThemeId | null {
return this.active?.components?.[component] ?? null;
}
private getAnyEntry(themeId: ThemeId | null | undefined): ThemeMeta | null {
if (!themeId || !this.manifest) return null;
return (
this.manifest.themes.find((t) => t.id === themeId) ||
this.manifest.fonts?.find((t) => t.id === themeId) ||
this.manifest.aod?.find((t) => t.id === themeId) ||
null
);
}
private getFullThemeEntry(themeId: ThemeId | null | undefined): ThemeMeta | null {
if (!themeId || !this.manifest) return null;
return this.manifest.themes.find((t) => t.id === themeId) || null;
}
private getBestDefaultThemeId(): ThemeId | null {
const m = this.manifest;
if (!m) return THEME_CONFIG.defaultThemeId;
const configuredId = THEME_CONFIG.defaultThemeId;
if (configuredId && m.themes.some((t) => t.id === configuredId)) return configuredId;
if (m.defaultThemeId && m.themes.some((t) => t.id === m.defaultThemeId)) return m.defaultThemeId;
return m.themes[0]?.id || configuredId || null;
}
private saveActiveConfig(cfg: ActiveThemeConfigV2) {
debouncedSetItem(THEME_CONFIG.storageKey, JSON.stringify(cfg));
}
private loadActiveConfig(): ActiveThemeConfigV2 | null {
const raw = safeJsonParse<any>(localStorage.getItem(THEME_CONFIG.storageKey));
return raw ? normalizeActiveConfig(raw, THEME_CONFIG.defaultThemeId) : null;
}
private async loadManifest(forceReload = false): Promise<ThemeManifest> {
if (this.manifest && !forceReload) return this.manifest;
if (this.manifestPromise && !forceReload) return this.manifestPromise;
this.manifestPromise = fetchJson(THEME_CONFIG.manifestUrl)
.then((raw) => normalizeManifest(raw))
.catch((e) => {
// Dev 环境下 themes 资源可能不存在(或被 SPA fallback 成 HTML),这里降级为“空 manifest”,避免阻塞启动。
console.warn('[ThemeService] Failed to load theme manifest, falling back to empty manifest:', e);
return normalizeManifest({ version: 1, themes: [], fonts: [], aod: [], defaultThemeId: null });
})
.then((m) => {
this.manifest = m;
return m;
})
.finally(() => {
this.manifestPromise = null;
});
return this.manifestPromise;
}
async init(): Promise<void> {
const manifest = await this.loadManifest(false);
const cfg = this.loadActiveConfig();
const defaultId = this.getBestDefaultThemeId();
const baseId = cfg?.themeId && manifest.themes.some((t) => t.id === cfg.themeId) ? cfg.themeId : defaultId;
if (!baseId) {
this.active = null;
this.currentTheme = null;
this.emitChange();
return;
}
this.active = cfg ? normalizeActiveConfig(cfg, baseId) : buildDefaultActiveConfig(baseId);
// Validate component theme ids
for (const k of Object.keys(this.active.components) as ThemeComponentKey[]) {
const tid = this.active.components[k];
if (!manifest.themes.some((t) => t.id === tid)) {
this.active.components[k] = baseId;
}
}
this.currentTheme = this.getFullThemeEntry(this.active.themeId);
this.saveActiveConfig(this.active);
// Clear caches on init (manifest may change in dev)
this.appAssetCache.clear();
this.appAssetMiss.clear();
this.appAssetInFlight.clear();
this.emitChange();
}
async getInstalledThemes(forceReload = false): Promise<ThemeMeta[]> {
const m = await this.loadManifest(forceReload);
// Sort: prefer free first, then title
const list = [...m.themes];
list.sort((a, b) => {
const pa = a.price ?? 0;
const pb = b.price ?? 0;
if (pa !== pb) return pa - pb;
return a.title.localeCompare(b.title, 'zh-CN');
});
return list;
}
async getFonts(forceReload = false): Promise<ThemeMeta[]> {
const m = await this.loadManifest(forceReload);
return m.fonts || [];
}
async getAod(forceReload = false): Promise<ThemeMeta[]> {
const m = await this.loadManifest(forceReload);
return m.aod || [];
}
async loadThemeMeta(themeId: ThemeId): Promise<ThemeMeta | null> {
const m = await this.loadManifest(false);
return m.themes.find((t) => t.id === themeId) || null;
}
/** Returns absolute preview URLs for a theme. */
getThemePreviewUrls(themeId: ThemeId): string[] {
const t = this.getAnyEntry(themeId);
const names = (t?.builtInPreviews?.length ? t.builtInPreviews : t?.previews) || [];
if (!names.length) return [];
return names.map((n) => `${THEME_CONFIG.baseUrl}/${themeId}/preview/${n}`);
}
getThemeWallpaperUrl(themeId: ThemeId): string | null {
const t = this.getFullThemeEntry(themeId);
const name = t?.extracted?.wallpaper?.default;
if (typeof name !== 'string' || !name) return null;
return `${THEME_CONFIG.baseUrl}/${themeId}/wallpaper/${name}`;
}
async applyTheme(themeId: ThemeId): Promise<void> {
const m = await this.loadManifest(false);
const meta = m.themes.find((t) => t.id === themeId) || null;
if (!meta) throw new Error(`[ThemeService] Theme not found in manifest: ${themeId}`);
this.active = buildActiveConfig(themeId);
this.currentTheme = meta;
this.saveActiveConfig(this.active);
this.appAssetCache.clear();
this.appAssetMiss.clear();
this.appAssetInFlight.clear();
this.emitChange();
}
async applyComponent(themeId: ThemeId, component: ThemeComponentCode): Promise<void> {
if (!this.active) await this.init();
if (!this.active) throw new Error('[ThemeService] Not initialized');
const m = await this.loadManifest(false);
if (!m.themes.some((t) => t.id === themeId)) throw new Error(`[ThemeService] Theme not found: ${themeId}`);
const map: Partial<Record<ThemeComponentCode, ThemeComponentKey>> = {
icons: 'icons',
statusbar: 'statusbar',
shade: 'shade',
};
const key = map[component];
if (!key) {
console.warn('[ThemeService] applyComponent: unsupported component key', component);
return;
}
this.active.components[key] = themeId;
this.active.appliedAt = timeNow();
this.saveActiveConfig(this.active);
// Component changes may affect asset resolution
if (key === 'icons' || key === 'statusbar' || key === 'shade') {
// For safety, clear appAsset caches (base theme may stay same, but user expects consistency)
this.appAssetCache.clear();
this.appAssetMiss.clear();
this.appAssetInFlight.clear();
}
this.emitChange();
}
// ----- App icons -----
getAppIcon(packageName: string | undefined | null): string | null {
if (!packageName || !this.active || !this.manifest) return null;
const themeId = this.active.components.icons || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
if (!t?.iconPackageNames?.includes(packageName)) return null;
return `${THEME_CONFIG.baseUrl}/${themeId}/icons/${packageName}.png`;
}
async getAppIconAsync(packageName: string | undefined | null): Promise<string | null> {
return this.getAppIcon(packageName);
}
// ----- Status bar icons -----
getStatusBarIcon(name: string): string | null {
if (!name || !this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
if (!t?.statusbarIcons?.includes(name)) return null;
return `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/${name}.png`;
}
getStatusBarWifiIcon(level: number): string | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const n = typeof t?.statusbarDynamic?.wifiLevels === 'number' ? (t.statusbarDynamic?.wifiLevels ?? 0) : 0;
if (n > 0) {
const idx = Math.min(Math.max(Math.floor(level || 0), 0), n - 1);
return `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/wifi_${idx}.png`;
}
return this.getStatusBarIcon('wifi');
}
getStatusBarSignalIcon(level: number): string | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const n = typeof t?.statusbarDynamic?.signalLevels === 'number' ? (t.statusbarDynamic?.signalLevels ?? 0) : 0;
if (n > 0) {
const idx = Math.min(Math.max(Math.floor(level || 0), 0), n - 1);
return `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/signal_${idx}.png`;
}
return this.getStatusBarIcon('signal');
}
getStatusBarBatterySprite(): { url: string; frames: number; frameSide: number } | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const dyn = t?.statusbarDynamic;
const frames = typeof dyn?.batteryFrames === 'number' ? dyn.batteryFrames : 0;
const sprite = typeof dyn?.batterySprite === 'string' ? dyn.batterySprite : '';
if (!frames || frames <= 0 || !sprite) {
// Defensive fallback for older / externally-authored manifests that
// registered `'battery'` in `statusbarIcons` but never set `batterySprite`.
// Treat the static `battery.png` as a 1-frame sprite so the front-end
// tinting path applies (charging green / saver yellow / low red).
const fallback = this.getStatusBarIcon('battery');
if (fallback) {
return { url: fallback, frames: 1, frameSide: 40 };
}
return null;
}
const frameSide = typeof dyn?.batteryFrameSide === 'number' ? dyn.batteryFrameSide : 40;
return {
url: `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/${sprite}`,
frames,
frameSide,
};
}
/**
* Lookup a state-specific battery sprite that the theme baked colors into.
* Returns null when the theme didn't ship that variant — caller should fall
* back to `getStatusBarBatterySprite()` plus a runtime color tint.
*
* `state` priority is the caller's responsibility (we just answer what's
* available, e.g. if asked for `power_save_charge` and the theme didn't
* ship it, returns null even though `charge` exists).
*/
getStatusBarBatteryVariantSprite(
state: 'charge' | 'power_save' | 'power_save_charge',
): { url: string; frames: number; frameSide: number } | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const dyn = t?.statusbarDynamic;
if (!dyn) return null;
const map = {
charge: { f: dyn.batteryChargeFrames, s: dyn.batteryChargeSprite, sd: dyn.batteryChargeFrameSide },
power_save: { f: dyn.batteryPowerSaveFrames, s: dyn.batteryPowerSaveSprite, sd: dyn.batteryPowerSaveFrameSide },
power_save_charge: { f: dyn.batteryPowerSaveChargeFrames, s: dyn.batteryPowerSaveChargeSprite, sd: dyn.batteryPowerSaveChargeFrameSide },
} as const;
const entry = map[state];
const frames = typeof entry.f === 'number' ? entry.f : 0;
const sprite = typeof entry.s === 'string' ? entry.s : '';
if (!frames || frames <= 0 || !sprite) return null;
const frameSide = typeof entry.sd === 'number' ? entry.sd : 40;
return {
url: `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/${sprite}`,
frames,
frameSide,
};
}
/**
* Static charging-bolt overlay. Composited on top of the normal battery
* sprite (in a contrast color) when the device is charging — visible at
* any fill level, including 100% where a "bolt baked into the sprite"
* approach would be hidden by the body fill.
*/
getStatusBarBatteryBoltOverlay(): string | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.statusbar || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const dyn = t?.statusbarDynamic;
const overlay = typeof dyn?.batteryBoltOverlay === 'string' ? dyn.batteryBoltOverlay : '';
if (!overlay) return null;
return `${THEME_CONFIG.baseUrl}/${themeId}/statusbar/${overlay}`;
}
getStatusBarDataTypeIcon(type: string): string | null {
if (!type) return null;
return this.getStatusBarIcon(`data_${type}`);
}
async getStatusBarIconAsync(name: string): Promise<string | null> {
return this.getStatusBarIcon(name);
}
// ----- System shade backgrounds -----
getShadeTileBackground(state: 'active' | 'inactive'): string | null {
if (!this.active || !this.manifest) return null;
const themeId = this.active.components.shade || this.active.themeId;
const t = this.getFullThemeEntry(themeId);
const ok = t?.capabilities?.includes('shade') || t?.extracted?.shade;
if (!ok) return null;
return `${THEME_CONFIG.baseUrl}/${themeId}/shade/${state}.png`;
}
async getShadeTileBackgroundAsync(state: 'active' | 'inactive'): Promise<string | null> {
return this.getShadeTileBackground(state);
}
// ----- Per-app themed assets (settings icons, SMS bubbles, dialer keys, etc.) -----
getAppAsset(component: string, assetName: string): string | null {
const themeId = this.active?.themeId;
if (!themeId) return null;
const key = `${themeId}|${component}|${assetName}`;
const cached = this.appAssetCache.get(key);
if (cached) return cached;
for (const n of this.assetNamesToTry(component, assetName)) {
const resolved = this.resolveFromManifest(themeId, component, n);
if (typeof resolved === 'string') {
this.appAssetCache.set(key, resolved);
return resolved;
}
}
return null;
}
private assetNamesToTry(component: string, assetName: string): string[] {
const alt: string[] = [];
if (component === 'contact' && assetName === 'call_button') {
alt.push('dialer_btn_call_bg', 'dialer_btn_call_normal', 'btn_call_sim1_normal', 'btn_call_sim2_normal');
}
return [assetName, ...uniq(alt)];
}
private resolveFromManifest(themeId: ThemeId, component: string, assetName: string): string | null | undefined {
const theme = this.manifest?.themes.find((t) => t.id === themeId);
const compAssets = theme?.componentAssets?.[component];
if (!compAssets) return undefined; // no manifest data — fall back to probe
const relPath = compAssets[assetName];
if (!relPath) return null; // manifest confirms asset does not exist
return `${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/${relPath}`;
}
async getAppAssetAsync(component: string, assetName: string): Promise<string | null> {
const themeId = this.active?.themeId;
if (!themeId) return null;
const key = `${themeId}|${component}|${assetName}`;
const cached = this.appAssetCache.get(key);
if (cached) return cached;
if (this.appAssetMiss.has(key)) return null;
// Fast path: resolve from manifest index (no network requests)
const tryNames = this.assetNamesToTry(component, assetName);
for (const n of tryNames) {
const resolved = this.resolveFromManifest(themeId, component, n);
if (resolved === null) continue; // this name not in manifest, try next alt
if (resolved !== undefined) {
this.appAssetCache.set(key, resolved);
return resolved;
}
break; // undefined = no manifest data for this component, fall through to probe
}
// If manifest had data for this component but none of the names matched, it's a confirmed miss
const theme = this.manifest?.themes.find((t) => t.id === themeId);
if (theme?.componentAssets?.[component]) {
this.appAssetMiss.add(key);
return null;
}
// Slow path: no manifest data for this component — probe URLs (legacy fallback)
const inflight = this.appAssetInFlight.get(key);
if (inflight) return inflight;
const promise = (async () => {
const candidates: string[] = [];
for (const n of tryNames) {
candidates.push(
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/res/drawable-xxhdpi/${n}.png`,
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/res/drawable-xhdpi/${n}.png`,
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/res/drawable/${n}.png`,
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/${n}.png`,
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/${n}.9.png`,
`${THEME_CONFIG.baseUrl}/${themeId}/components/${component}/res/drawable-xxhdpi/${n}.9.png`,
);
}
for (const url of candidates) {
if (await urlExists(url)) {
this.appAssetCache.set(key, url);
return url;
}
}
this.appAssetMiss.add(key);
return null;
})()
.catch(() => null)
.finally(() => {
this.appAssetInFlight.delete(key);
});
this.appAssetInFlight.set(key, promise);
return promise;
}
}
export const ThemeService = new ThemeServiceImpl();