super-productivity/src/app/plugins/plugin-loader.service.ts
2026-05-18 13:43:22 +02:00

193 lines
6 KiB
TypeScript

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { first } from 'rxjs/operators';
import { PluginManifest } from './plugin-api.model';
import { PluginCacheService } from './plugin-cache.service';
// KISS: No size checks in loader - trust the browser's limits
import { validatePluginManifest } from './util/validate-manifest.util';
import { PluginLog } from '../core/log';
interface PluginAssets {
manifest: PluginManifest;
code: string;
indexHtml?: string;
icon?: string;
translations?: Record<string, string>; // language code -> JSON string
}
/**
* Simplified plugin loader following KISS principles.
* Just load plugins - no complex state tracking or preloading.
*/
@Injectable({
providedIn: 'root',
})
export class PluginLoaderService {
private readonly _http = inject(HttpClient);
private readonly _cacheService = inject(PluginCacheService);
/**
* Load plugin assets - simple and direct
*/
async loadPluginAssets(pluginPath: string): Promise<PluginAssets> {
// Handle uploaded plugins differently
if (pluginPath.startsWith('uploaded://')) {
const pluginId = pluginPath.replace('uploaded://', '');
return this.loadUploadedPluginAssets(pluginId);
}
try {
// Load manifest
const manifestUrl = `${pluginPath}/manifest.json`;
const manifestText = await this._http
.get(manifestUrl, { responseType: 'text' })
.pipe(first())
.toPromise();
const manifest: PluginManifest = JSON.parse(manifestText);
// Basic validation
const validation = validatePluginManifest(manifest);
if (!validation.isValid) {
throw new Error(`Invalid manifest: ${validation.errors.join(', ')}`);
}
// Log validation warnings
if (validation.warnings.length > 0) {
validation.warnings.forEach((warning) => {
PluginLog.warn(`[PluginLoader] ${manifest.id}: ${warning}`);
});
}
// Load optional assets
let indexHtml: string | undefined;
let icon: string | undefined;
let code = '';
let hasPluginJs = false;
let pluginJsLoadError: unknown;
// Load plugin code. Iframe-only plugins can omit plugin.js when index.html is
// available, so defer throwing until optional iframe assets have been checked.
const codeUrl = `${pluginPath}/plugin.js`;
try {
code =
(await this._http
.get(codeUrl, { responseType: 'text' })
.pipe(first())
.toPromise()) ?? '';
hasPluginJs = true;
} catch (error) {
if (!manifest.iFrame || !this._isOptionalAssetNotFound(error)) {
throw error;
}
pluginJsLoadError = error;
}
if (manifest.iFrame) {
try {
const htmlUrl = `${pluginPath}/index.html`;
indexHtml = await this._http
.get(htmlUrl, { responseType: 'text' })
.pipe(first())
.toPromise();
} catch (e) {
PluginLog.err(`No index.html for plugin ${manifest.id}`);
if (!hasPluginJs) {
throw pluginJsLoadError ?? e;
}
}
}
if (!hasPluginJs && !this._hasPluginIndexHtml(indexHtml)) {
throw new Error(
`Plugin ${manifest.id} requires a non-empty index.html when plugin.js is missing`,
);
}
if (manifest.icon) {
try {
const iconUrl = `${pluginPath}/${manifest.icon}`;
icon = await this._http
.get(iconUrl, { responseType: 'text' })
.pipe(first())
.toPromise();
} catch (e) {
PluginLog.err(`No icon for plugin ${manifest.id}`);
}
}
// Load translation files if i18n is configured (in parallel)
let translations: Record<string, string> | undefined;
if (manifest.i18n?.languages && manifest.i18n.languages.length > 0) {
const translationPromises = manifest.i18n.languages.map(async (lang) => {
try {
const translationUrl = `${pluginPath}/i18n/${lang}.json`;
const translationContent = await firstValueFrom(
this._http.get(translationUrl, { responseType: 'text' }),
);
return [lang, translationContent] as const;
} catch (e) {
PluginLog.err(
`[PluginLoader] Failed to load ${lang} translations for ${manifest.id}:`,
e,
);
return [lang, null] as const;
}
});
const results = await Promise.all(translationPromises);
translations = {};
for (const [lang, content] of results) {
if (content) {
translations[lang] = content;
PluginLog.log(
`[PluginLoader] Loaded ${lang} translations for ${manifest.id}`,
);
}
}
}
return { manifest, code, indexHtml, icon, translations };
} catch (error) {
PluginLog.err(`Failed to load plugin from ${pluginPath}:`, error);
throw error;
}
}
/**
* Load uploaded plugin assets from cache
*/
async loadUploadedPluginAssets(pluginId: string): Promise<PluginAssets> {
const cached = await this._cacheService.getPlugin(pluginId);
if (!cached) {
throw new Error(`Plugin ${pluginId} not found in cache`);
}
const manifest: PluginManifest = JSON.parse(cached.manifest);
return {
manifest,
code: cached.code,
indexHtml: cached.indexHtml,
icon: cached.icon,
translations: cached.translations,
};
}
/**
* Clear all caches - KISS: just delegate to cache service
*/
async clearAllCaches(): Promise<void> {
await this._cacheService.clearCache();
}
private _isOptionalAssetNotFound(error: unknown): boolean {
return (
error instanceof HttpErrorResponse && (error.status === 0 || error.status === 404)
);
}
private _hasPluginIndexHtml(indexHtml: string | undefined): boolean {
return indexHtml !== undefined && indexHtml.trim().length > 0;
}
}