项目文件夹

文件
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 行
29 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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(RGBA,body 是主题选定的绿/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();