mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
193 lines
6 KiB
TypeScript
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;
|
|
}
|
|
}
|