mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes
Wires the dead PluginHooks.PERSISTED_DATA_UPDATE enum into a fired hook,
renamed PERSISTED_DATA_CHANGED. Plugins are notified when their persisted
data changes for any reason after the host's initial boot load — local
writes, remote incremental sync via bulkApplyOperations, and post-boot
wholesale loadAllData paths (SYNC_IMPORT / BACKUP_IMPORT / validation
repair / recovery).
Selector-based effect on selectPluginUserDataFeatureState, gated on
SyncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$ so the
boot-time state seeds the pairwise baseline. Differ compares prev/next
by === on the encoded data blob (never decoded). Stage A composite
entityIds (pluginId:key) are normalized to owner pluginId and deduped
so a plugin with N keyed entries changing in one emission fires exactly
once. Per-pluginId dispatch via new PluginHooksService.dispatchHookToPlugin.
Effect is { dispatch: false } and creates no ops, so no sync-window
guard is needed — and adding one (skipDuringSyncWindow) would silently
suppress the very remote-sync deliveries the hook is designed to catch.
Closes #7754. Follow-up: doc-mode adoption tracked in #7752.
* test(plugins): tighten PERSISTED_DATA_CHANGED spec + docs
Multi-review pass 2 surfaced that the 5s timeout spec asserted only that
the dispatcher resolved — it would have silently passed if the timeout
race were removed entirely. Spy on PluginLog.err and assert the timeout
message actually reached the catch branch.
Also:
- Add `:` guard to PluginHooksService.registerHookHandler so the
persistence-key grammar is enforced at both the persistence and
hooks-registry endpoints (defense-in-depth; composeId already throws
at the bridge).
- Add async-rejected-promise spec to cover the Promise.race branch that
sync-throw didn't exercise.
- Carry the PERSISTED_DATA_CHANGED contract paragraph into
docs/plugin-development.md and docs/wiki/3.01-API.md (previously only
in packages/plugin-api/README.md).
- Clarify loadSyncedData(key?) in the README for keyed plugins.
1799 lines
61 KiB
TypeScript
1799 lines
61 KiB
TypeScript
/* eslint-disable max-len */
|
|
import { inject, Injectable, signal, OnDestroy } from '@angular/core';
|
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
import { environment } from '../../environments/environment';
|
|
import { PluginRunner } from './plugin-runner';
|
|
import { PluginHooksService } from './plugin-hooks';
|
|
import { PluginSecurityService } from './plugin-security';
|
|
import { Hooks, PluginBaseCfg, PluginInstance, PluginManifest } from './plugin-api.model';
|
|
import { PluginState } from './plugin-state.model';
|
|
import { GlobalThemeService } from '../core/theme/global-theme.service';
|
|
import { IS_ANDROID_WEB_VIEW } from '../util/is-android-web-view';
|
|
import { IS_ELECTRON } from '../app.constants';
|
|
import { PluginMetaPersistenceService } from './plugin-meta-persistence.service';
|
|
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
|
|
import { PluginCacheService } from './plugin-cache.service';
|
|
import {
|
|
MAX_PLUGIN_CODE_SIZE,
|
|
MAX_PLUGIN_MANIFEST_SIZE,
|
|
MAX_PLUGIN_ZIP_SIZE,
|
|
} from './plugin.const';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import {
|
|
PluginNodeConsentDialogComponent,
|
|
PluginNodeConsentDialogData,
|
|
} from './ui/plugin-node-consent-dialog/plugin-node-consent-dialog-simple.component';
|
|
import { first, take } from 'rxjs/operators';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import { PluginCleanupService } from './plugin-cleanup.service';
|
|
import { PluginLoaderService } from './plugin-loader.service';
|
|
import { validatePluginManifest } from './util/validate-manifest.util';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import { T } from '../t.const';
|
|
import { PluginLog } from '../core/log';
|
|
import { PluginI18nService } from './plugin-i18n.service';
|
|
import { Store } from '@ngrx/store';
|
|
import { issueProvidersFeature } from '../features/issue/store/issue-provider.reducer';
|
|
import { selectIsDominaModeConfig } from '../features/config/store/global-config.reducer';
|
|
import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service';
|
|
import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service';
|
|
import { SnackService } from '../core/snack/snack.service';
|
|
import { pingWithRetry } from './util/ping-with-retry.util';
|
|
import {
|
|
decideNodeExecutionConsent,
|
|
NodeExecutionConsentDialogResult,
|
|
} from './util/plugin-consent.util';
|
|
|
|
const BUNDLED_PLUGIN_PATHS = [
|
|
'assets/bundled-plugins/yesterday-tasks-plugin',
|
|
'assets/bundled-plugins/sync-md',
|
|
'assets/bundled-plugins/api-test-plugin',
|
|
'assets/bundled-plugins/procrastination-buster',
|
|
'assets/bundled-plugins/automations',
|
|
'assets/bundled-plugins/github-issue-provider',
|
|
'assets/bundled-plugins/clickup-issue-provider',
|
|
'assets/bundled-plugins/brain-dump',
|
|
'assets/bundled-plugins/voice-reminder',
|
|
'assets/bundled-plugins/google-calendar-provider',
|
|
'assets/bundled-plugins/caldav-calendar-provider',
|
|
'assets/bundled-plugins/document-mode',
|
|
] as const;
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class PluginService implements OnDestroy {
|
|
private readonly _http = inject(HttpClient);
|
|
private readonly _pluginRunner = inject(PluginRunner);
|
|
private readonly _pluginHooks = inject(PluginHooksService);
|
|
private readonly _pluginSecurity = inject(PluginSecurityService);
|
|
private readonly _globalThemeService = inject(GlobalThemeService);
|
|
private readonly _pluginMetaPersistenceService = inject(PluginMetaPersistenceService);
|
|
private readonly _pluginUserPersistenceService = inject(PluginUserPersistenceService);
|
|
private readonly _pluginCacheService = inject(PluginCacheService);
|
|
private readonly _dialog = inject(MatDialog);
|
|
private readonly _cleanupService = inject(PluginCleanupService);
|
|
private readonly _pluginLoader = inject(PluginLoaderService);
|
|
private readonly _translateService = inject(TranslateService);
|
|
private readonly _pluginI18nService = inject(PluginI18nService);
|
|
private readonly _store = inject(Store);
|
|
private readonly _pluginIssueProviderRegistry = inject(
|
|
PluginIssueProviderRegistryService,
|
|
);
|
|
private readonly _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService);
|
|
private readonly _snackService = inject(SnackService);
|
|
|
|
private _isInitialized = false;
|
|
private _loadedPlugins: PluginInstance[] = [];
|
|
private _pluginPaths: Map<string, string> = new Map(); // Store plugin ID -> path mapping
|
|
private _pluginIndexHtml: Map<string, string> = new Map(); // Store plugin ID -> index.html content
|
|
private _pluginIcons: Map<string, string> = new Map(); // Store plugin ID -> SVG icon content
|
|
private _pluginIconsSignal = signal<Map<string, string>>(new Map());
|
|
|
|
// Lazy loading state management
|
|
private _pluginStates = signal<Map<string, PluginState>>(new Map());
|
|
public readonly pluginStates = this._pluginStates.asReadonly();
|
|
|
|
// Track active side panel plugin
|
|
private _activeSidePanelPlugin$ = new BehaviorSubject<PluginInstance | null>(null);
|
|
public readonly activeSidePanelPlugin$ = this._activeSidePanelPlugin$.asObservable();
|
|
|
|
async initializePlugins(): Promise<void> {
|
|
if (this._isInitialized) {
|
|
PluginLog.err(this._translateService.instant(T.PLUGINS.ALREADY_INITIALIZED));
|
|
return;
|
|
}
|
|
|
|
PluginLog.log('Initializing plugin system...');
|
|
|
|
try {
|
|
// Only load manifests, not the actual plugin code
|
|
await this._discoverBuiltInPlugins();
|
|
await this._discoverUploadedPlugins();
|
|
|
|
// Load all enabled plugins on startup
|
|
await this._loadEnabledPlugins();
|
|
|
|
this._isInitialized = true;
|
|
PluginLog.log('Plugin system initialized successfully');
|
|
} catch (error) {
|
|
PluginLog.err('Failed to initialize plugin system:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async _discoverBuiltInPlugins(): Promise<void> {
|
|
const pluginPaths = [
|
|
...BUNDLED_PLUGIN_PATHS,
|
|
'assets/bundled-plugins/ai-productivity-prompts', // discover-only
|
|
];
|
|
|
|
// Only load manifests for discovery
|
|
for (const path of pluginPaths) {
|
|
try {
|
|
const manifestUrl = `${path}/manifest.json`;
|
|
const manifest = await this._http.get<PluginManifest>(manifestUrl).toPromise();
|
|
|
|
if (manifest) {
|
|
let isEnabled = await this._pluginMetaPersistenceService.isPluginEnabled(
|
|
manifest.id,
|
|
);
|
|
|
|
// Auto-enable bundled plugins that replace built-in issue providers,
|
|
// but only if the user has never interacted with the plugin before.
|
|
// If metadata exists (even with isEnabled: false), the user explicitly disabled it.
|
|
if (!isEnabled && manifest.issueProvider?.issueProviderKey) {
|
|
const hasMetadata =
|
|
await this._pluginMetaPersistenceService.hasPluginMetadata(manifest.id);
|
|
if (!hasMetadata) {
|
|
const shouldAutoEnable = await this._shouldAutoEnableMigrationPlugin(
|
|
manifest.issueProvider.issueProviderKey,
|
|
);
|
|
if (shouldAutoEnable) {
|
|
isEnabled = true;
|
|
await this._pluginMetaPersistenceService.setPluginEnabled(
|
|
manifest.id,
|
|
true,
|
|
);
|
|
PluginLog.log(
|
|
`Auto-enabled bundled plugin '${manifest.id}' (replaces built-in ${manifest.issueProvider.issueProviderKey} provider)`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-enable voice-reminder plugin if domina mode was enabled,
|
|
// and migrate config so users keep their settings.
|
|
if (!isEnabled && manifest.id === 'voice-reminder') {
|
|
const hasMetadata =
|
|
await this._pluginMetaPersistenceService.hasPluginMetadata(manifest.id);
|
|
if (!hasMetadata) {
|
|
const migrated = await this._migrateVoiceReminderFromDominaMode();
|
|
if (migrated) {
|
|
isEnabled = true;
|
|
await this._pluginMetaPersistenceService.setPluginEnabled(
|
|
manifest.id,
|
|
true,
|
|
);
|
|
PluginLog.log(
|
|
`Auto-enabled voice-reminder plugin (migrated from domina mode config)`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create plugin state without loading code
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'not-loaded',
|
|
path,
|
|
type: 'built-in',
|
|
isEnabled,
|
|
};
|
|
|
|
this._setPluginState(manifest.id, state);
|
|
this._pluginPaths.set(manifest.id, path);
|
|
|
|
// Load icon if available
|
|
try {
|
|
const iconUrl = `${path}/${manifest.icon || 'icon.svg'}`;
|
|
const icon = await this._http
|
|
.get(iconUrl, { responseType: 'text' })
|
|
.toPromise();
|
|
if (icon) {
|
|
state.icon = icon;
|
|
this._pluginIcons.set(manifest.id, icon);
|
|
this._updatePluginIcons();
|
|
}
|
|
} catch (e) {
|
|
// Icon is optional - silently ignore 404s and other errors
|
|
PluginLog.debug(
|
|
`Icon not found for plugin ${manifest.id}: ${path}/${manifest.icon || 'icon.svg'}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HttpErrorResponse &&
|
|
(error.status === 0 || error.status === 404)
|
|
) {
|
|
PluginLog.warn(
|
|
`Optional built-in plugin manifest missing at ${path} (status ${error.status}). Skipping.`,
|
|
);
|
|
} else {
|
|
PluginLog.err(`Failed to discover plugin at ${path}:`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async _loadBuiltInPlugins(): Promise<void> {
|
|
const pluginPaths = [...BUNDLED_PLUGIN_PATHS];
|
|
|
|
// KISS: No preloading - just load plugins directly
|
|
await this._loadPluginsFromPaths(pluginPaths, 'built-in');
|
|
}
|
|
|
|
private async _discoverUploadedPlugins(): Promise<void> {
|
|
try {
|
|
const cachedPlugins = await this._pluginCacheService.getAllPlugins();
|
|
|
|
for (const cachedPlugin of cachedPlugins) {
|
|
try {
|
|
const isEnabled = await this._pluginMetaPersistenceService.isPluginEnabled(
|
|
cachedPlugin.id,
|
|
);
|
|
|
|
// Parse the manifest from JSON string
|
|
const manifest: PluginManifest = JSON.parse(cachedPlugin.manifest);
|
|
|
|
// Create plugin state without loading code
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'not-loaded',
|
|
path: `uploaded://${cachedPlugin.id}`,
|
|
type: 'uploaded',
|
|
isEnabled,
|
|
icon: cachedPlugin.icon,
|
|
};
|
|
|
|
this._setPluginState(cachedPlugin.id, state);
|
|
this._pluginPaths.set(cachedPlugin.id, state.path);
|
|
|
|
if (cachedPlugin.icon) {
|
|
this._pluginIcons.set(cachedPlugin.id, cachedPlugin.icon);
|
|
this._registerPluginIcon(cachedPlugin.id, cachedPlugin.icon);
|
|
this._updatePluginIcons();
|
|
}
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to discover cached plugin ${cachedPlugin.id}:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
PluginLog.err('Failed to discover cached plugins:', error);
|
|
}
|
|
}
|
|
|
|
private async _loadEnabledPlugins(): Promise<void> {
|
|
// Load all enabled plugins on startup
|
|
const pluginsToLoad = Array.from(this._pluginStates().values()).filter(
|
|
(state) => state.isEnabled,
|
|
);
|
|
|
|
PluginLog.log(`Loading ${pluginsToLoad.length} enabled plugins...`);
|
|
|
|
// Log which plugins are being loaded
|
|
for (const state of pluginsToLoad) {
|
|
PluginLog.log(
|
|
`Loading plugin: ${state.manifest.id} (enabled: ${state.isEnabled}, hooks: ${state.manifest.hooks?.length || 0}, sidePanel: ${state.manifest.sidePanel})`,
|
|
);
|
|
}
|
|
|
|
for (const state of pluginsToLoad) {
|
|
await this.activatePlugin(state.manifest.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a migration plugin should be auto-enabled because existing
|
|
* issue providers with the matching key exist in the store.
|
|
*/
|
|
private async _shouldAutoEnableMigrationPlugin(
|
|
issueProviderKey: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
const allProviders = await this._store
|
|
.select(issueProvidersFeature.selectAll)
|
|
.pipe(take(1))
|
|
.toPromise();
|
|
return allProviders?.some((p) => p.issueProviderKey === issueProviderKey) ?? false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrate domina mode (voice reminder) config to the voice-reminder plugin.
|
|
* Returns true if migration was performed (domina mode was enabled).
|
|
*/
|
|
private async _migrateVoiceReminderFromDominaMode(): Promise<boolean> {
|
|
try {
|
|
const cfg = await firstValueFrom(
|
|
this._store.select(selectIsDominaModeConfig).pipe(take(1)),
|
|
);
|
|
if (!cfg?.isEnabled) {
|
|
return false;
|
|
}
|
|
const pluginData = JSON.stringify({
|
|
isEnabled: cfg.isEnabled,
|
|
text: cfg.text,
|
|
interval: cfg.interval,
|
|
volume: cfg.volume,
|
|
voice: cfg.voice || '',
|
|
});
|
|
this._pluginUserPersistenceService.persistPluginUserData(
|
|
'voice-reminder',
|
|
pluginData,
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
PluginLog.err('Failed to migrate voice reminder config from domina mode:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns discovered issue provider plugins that are NOT yet enabled.
|
|
* Used by the issue-provider setup overview to show available plugins.
|
|
*/
|
|
getDisabledIssueProviderPlugins(): Array<{
|
|
pluginId: string;
|
|
name: string;
|
|
icon: string;
|
|
issueProviderKey: string;
|
|
useAgendaView: boolean;
|
|
}> {
|
|
const result: Array<{
|
|
pluginId: string;
|
|
name: string;
|
|
icon: string;
|
|
issueProviderKey: string;
|
|
useAgendaView: boolean;
|
|
}> = [];
|
|
for (const [, state] of this._pluginStates()) {
|
|
if (!state.isEnabled && state.manifest.type === 'issueProvider') {
|
|
result.push({
|
|
pluginId: state.manifest.id,
|
|
name: state.manifest.name,
|
|
icon: state.manifest.issueProvider?.icon || 'extension',
|
|
issueProviderKey:
|
|
state.manifest.issueProvider?.issueProviderKey ??
|
|
`plugin:${state.manifest.id}`,
|
|
useAgendaView: state.manifest.issueProvider?.useAgendaView ?? false,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Enable and activate a plugin. Returns the activated instance.
|
|
*/
|
|
async enableAndActivatePlugin(pluginId: string): Promise<PluginInstance | null> {
|
|
await this._pluginMetaPersistenceService.setPluginEnabled(pluginId, true);
|
|
return this.activatePlugin(pluginId, true);
|
|
}
|
|
|
|
private _updatePluginIcons(): void {
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
private _registerPluginIcon(pluginId: string, iconContent: string): void {
|
|
const iconName = `plugin-${pluginId}-icon`;
|
|
this._globalThemeService.registerSvgIconFromContent(iconName, iconContent);
|
|
}
|
|
|
|
private _setPluginState(pluginId: string, state: PluginState): void {
|
|
this._pluginStates.update((states) => {
|
|
const newStates = new Map(states);
|
|
newStates.set(pluginId, state);
|
|
return newStates;
|
|
});
|
|
}
|
|
|
|
private _deletePluginState(pluginId: string): void {
|
|
this._pluginStates.update((states) => {
|
|
const newStates = new Map(states);
|
|
newStates.delete(pluginId);
|
|
return newStates;
|
|
});
|
|
}
|
|
|
|
private _getPluginState(pluginId: string): PluginState | undefined {
|
|
return this._pluginStates().get(pluginId);
|
|
}
|
|
|
|
/**
|
|
* Activate a plugin (load it if not already loaded)
|
|
* @param isManualActivation - true when user manually enables plugin, false on startup
|
|
*/
|
|
async activatePlugin(
|
|
pluginId: string,
|
|
isManualActivation: boolean = false,
|
|
): Promise<PluginInstance | null> {
|
|
const state = this._getPluginState(pluginId);
|
|
if (!state) {
|
|
PluginLog.err(`Plugin ${pluginId} not found`);
|
|
return null;
|
|
}
|
|
|
|
// If manually activated, ensure the state reflects that it's enabled
|
|
if (isManualActivation && !state.isEnabled) {
|
|
this._setPluginState(pluginId, {
|
|
...state,
|
|
isEnabled: true,
|
|
});
|
|
}
|
|
|
|
// If already loaded, return the instance
|
|
if (state.status === 'loaded' && state.instance) {
|
|
return state.instance;
|
|
}
|
|
|
|
// If currently loading, wait for it
|
|
if (state.status === 'loading') {
|
|
// Wait for status to change (max 10 seconds)
|
|
const maxAttempts = 100;
|
|
let attempts = 0;
|
|
await new Promise<void>((resolve, reject) => {
|
|
const checkStatus = setInterval(() => {
|
|
attempts++;
|
|
if (attempts > maxAttempts) {
|
|
clearInterval(checkStatus);
|
|
reject(new Error(`Plugin loading timeout after ${maxAttempts * 100}ms`));
|
|
return;
|
|
}
|
|
const currentState = this._getPluginState(pluginId);
|
|
if (currentState && currentState.status !== 'loading') {
|
|
clearInterval(checkStatus);
|
|
resolve();
|
|
}
|
|
}, 100);
|
|
}).catch((err) => {
|
|
console.error('Plugin activation error:', err);
|
|
});
|
|
|
|
const updatedState = this._getPluginState(pluginId);
|
|
return updatedState?.instance || null;
|
|
}
|
|
|
|
// Get the updated state if it was just enabled
|
|
const currentState = isManualActivation
|
|
? this._getPluginState(pluginId) || state
|
|
: state;
|
|
|
|
// Only check for permission if plugin is actually enabled
|
|
if (currentState.isEnabled) {
|
|
// Only check permission on startup - manual activation already checked in UI
|
|
if (!isManualActivation) {
|
|
const hasConsent = await this._checkNodeExecutionPermissionForStartup(
|
|
currentState.manifest,
|
|
);
|
|
if (!hasConsent) {
|
|
console.log(
|
|
'Plugin requires Node.js execution permission but no stored consent found:',
|
|
state.manifest.id,
|
|
);
|
|
// Don't disable the plugin on startup - user may have enabled it but not granted permission yet
|
|
return null;
|
|
}
|
|
}
|
|
} else {
|
|
// Plugin is not enabled, don't activate it
|
|
console.log(`Plugin ${pluginId} is not enabled, skipping activation`);
|
|
return null;
|
|
}
|
|
|
|
// Load the plugin
|
|
this._setPluginState(pluginId, {
|
|
...currentState,
|
|
status: 'loading',
|
|
});
|
|
|
|
try {
|
|
PluginLog.log(`Activating plugin: ${pluginId}`);
|
|
const instance = await this._loadPluginLazy(currentState);
|
|
|
|
this._setPluginState(pluginId, {
|
|
...currentState,
|
|
status: 'loaded',
|
|
instance: instance,
|
|
});
|
|
|
|
// Add to loaded plugins list for compatibility
|
|
if (!this._loadedPlugins.find((p) => p.manifest.id === pluginId)) {
|
|
this._loadedPlugins.push(instance);
|
|
}
|
|
|
|
return instance;
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to activate plugin ${pluginId}:`, error);
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
|
// Tear down any partially-registered runtime (hooks, buttons, side effects)
|
|
// to avoid a "running" plugin while UI shows error state.
|
|
try {
|
|
this._pluginRunner.unloadPlugin(pluginId);
|
|
} catch (unloadError) {
|
|
PluginLog.err(
|
|
`Failed to clean up plugin ${pluginId} after activation error:`,
|
|
unloadError,
|
|
);
|
|
}
|
|
|
|
this._setPluginState(pluginId, {
|
|
...currentState,
|
|
status: 'error',
|
|
error: errorMsg,
|
|
});
|
|
// Only surface a snack on user-initiated activation. Startup auto-activation
|
|
// failures stay silent; the plugin tile already renders the error state, so
|
|
// a pile-up of snacks on cold boot would be noise.
|
|
if (isManualActivation) {
|
|
this._snackService.open({
|
|
msg: this._translateService.instant(T.PLUGINS.PLUGIN_LOAD_FAILED, {
|
|
pluginName: currentState.manifest.name,
|
|
error: errorMsg,
|
|
}),
|
|
type: 'ERROR',
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async _loadPluginLazy(state: PluginState): Promise<PluginInstance> {
|
|
// Load the plugin code and assets
|
|
const assets = await this._pluginLoader.loadPluginAssets(state.path);
|
|
const { code: pluginCode, indexHtml, translations } = assets;
|
|
|
|
// Store assets
|
|
if (indexHtml) {
|
|
this._pluginIndexHtml.set(state.manifest.id, indexHtml);
|
|
}
|
|
|
|
// Load translations into i18n service
|
|
if (translations && Object.keys(translations).length > 0) {
|
|
this._pluginI18nService.loadPluginTranslationsFromContent(
|
|
state.manifest.id,
|
|
translations,
|
|
);
|
|
}
|
|
|
|
// Create base config
|
|
const baseCfg = this._getBaseCfg();
|
|
|
|
// Run the plugin
|
|
const instance = await this._pluginRunner.loadPlugin(
|
|
state.manifest,
|
|
pluginCode,
|
|
baseCfg,
|
|
state.isEnabled,
|
|
);
|
|
|
|
await this._fireOnReady(instance);
|
|
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Fire onReady for a successfully loaded plugin. For nodeExecution plugins on
|
|
* Electron, pings the IPC bridge first (with retry); throws if the bridge stays
|
|
* unavailable. Called after every plugin load path.
|
|
*/
|
|
private async _fireOnReady(instance: PluginInstance): Promise<void> {
|
|
if (!instance.loaded) {
|
|
return;
|
|
}
|
|
if (IS_ELECTRON && instance.manifest.permissions?.includes('nodeExecution')) {
|
|
await this._pingNodeBridge(instance.manifest);
|
|
}
|
|
await this._pluginRunner.triggerReady(instance.manifest.id);
|
|
}
|
|
|
|
/**
|
|
* Same as _fireOnReady but tears down the plugin runtime if onReady fails,
|
|
* so we never leave a "running" plugin while the UI shows error state.
|
|
* Used by load paths whose outer catch only logs and rethrows.
|
|
*/
|
|
private async _fireOnReadyWithCleanup(instance: PluginInstance): Promise<void> {
|
|
try {
|
|
await this._fireOnReady(instance);
|
|
} catch (error) {
|
|
this._handleReadyFailure(instance, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tear down a plugin's runtime (hooks, buttons, side effects), remove it from
|
|
* the loaded list, and update its state to 'error'. Idempotent.
|
|
*/
|
|
private _handleReadyFailure(instance: PluginInstance, error: unknown): void {
|
|
const pluginId = instance.manifest.id;
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
PluginLog.err(`onReady failed for plugin ${pluginId}:`, error);
|
|
|
|
try {
|
|
this._pluginRunner.unloadPlugin(pluginId);
|
|
} catch (unloadError) {
|
|
PluginLog.err(
|
|
`Failed to clean up plugin ${pluginId} after onReady error:`,
|
|
unloadError,
|
|
);
|
|
}
|
|
|
|
const idx = this._loadedPlugins.findIndex((p) => p.manifest.id === pluginId);
|
|
if (idx !== -1) {
|
|
this._loadedPlugins.splice(idx, 1);
|
|
}
|
|
|
|
const currentState = this._pluginStates().get(pluginId);
|
|
if (currentState) {
|
|
this._setPluginState(pluginId, {
|
|
...currentState,
|
|
status: 'error',
|
|
error: errorMsg,
|
|
});
|
|
}
|
|
|
|
this._snackService.open({
|
|
msg: this._translateService.instant(T.PLUGINS.PLUGIN_LOAD_FAILED, {
|
|
pluginName: instance.manifest.name,
|
|
error: errorMsg,
|
|
}),
|
|
type: 'ERROR',
|
|
});
|
|
}
|
|
|
|
private async _loadUploadedPlugins(): Promise<void> {
|
|
try {
|
|
const cachedPlugins = await this._pluginCacheService.getAllPlugins();
|
|
|
|
const promises = cachedPlugins.map(async (cachedPlugin) => {
|
|
try {
|
|
PluginLog.log(`Loading cached plugin: ${cachedPlugin.id}`);
|
|
// Set the path for reload functionality
|
|
this._pluginPaths.set(cachedPlugin.id, `uploaded://${cachedPlugin.id}`);
|
|
|
|
// Load the cached plugin
|
|
await this._loadUploadedPlugin(cachedPlugin.id);
|
|
// The plugin instance is already added to _loadedPlugins in _loadUploadedPlugin if loaded successfully
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to load cached plugin ${cachedPlugin.id}:`, error);
|
|
// Continue loading other plugins even if one fails
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(promises);
|
|
} catch (error) {
|
|
PluginLog.err('Failed to load cached plugins:', error);
|
|
// Don't throw - this shouldn't prevent other plugins from loading
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load plugins from multiple paths with error handling
|
|
*/
|
|
private async _loadPluginsFromPaths(
|
|
pluginPaths: string[],
|
|
type: 'built-in' | 'uploaded',
|
|
): Promise<void> {
|
|
const promises = pluginPaths.map(async (pluginPath) => {
|
|
try {
|
|
const pluginInstance = await this._loadPlugin(pluginPath);
|
|
// Add all plugin instances to the loaded plugins list so they show up in the management UI
|
|
// Note: _loadPlugin already adds loaded plugins to _loadedPlugins, so we only need to add disabled ones
|
|
if (!pluginInstance.loaded && !pluginInstance.isEnabled) {
|
|
this._loadedPlugins.push(pluginInstance);
|
|
}
|
|
// Store the path for built-in plugins to enable reload functionality
|
|
// This ensures that built-in plugins can be reloaded just like uploaded ones
|
|
if (pluginInstance.manifest && pluginInstance.manifest.id) {
|
|
this._pluginPaths.set(pluginInstance.manifest.id, pluginPath);
|
|
}
|
|
PluginLog.log(`${type} plugin loaded successfully from ${pluginPath}`);
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to load ${type} plugin from ${pluginPath}:`, error);
|
|
// Continue loading other plugins even if one fails
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(promises);
|
|
}
|
|
|
|
private async _loadPlugin(pluginPath: string): Promise<PluginInstance> {
|
|
try {
|
|
// Use the loader service for lazy loading
|
|
const assets = await this._pluginLoader.loadPluginAssets(pluginPath);
|
|
const { manifest, code: pluginCode, indexHtml, icon, translations } = assets;
|
|
|
|
// Store assets if loaded
|
|
if (indexHtml) {
|
|
this._pluginIndexHtml.set(manifest.id, indexHtml);
|
|
}
|
|
if (icon) {
|
|
this._pluginIcons.set(manifest.id, icon);
|
|
this._registerPluginIcon(manifest.id, icon);
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
// Load translations into i18n service
|
|
if (translations && Object.keys(translations).length > 0) {
|
|
this._pluginI18nService.loadPluginTranslationsFromContent(
|
|
manifest.id,
|
|
translations,
|
|
);
|
|
}
|
|
|
|
// Check if plugin should be loaded based on persisted enabled state
|
|
const isPluginEnabled = await this._pluginMetaPersistenceService.isPluginEnabled(
|
|
manifest.id,
|
|
);
|
|
|
|
// Validate manifest and code
|
|
const manifestValidation = validatePluginManifest(manifest);
|
|
if (!manifestValidation.isValid) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.VALIDATION_FAILED, {
|
|
errors: manifestValidation.errors.join(', '),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Check for dangerous permissions
|
|
if (this._pluginSecurity.hasElevatedPermissions(manifest)) {
|
|
if (!IS_ELECTRON) {
|
|
// In web version, create a disabled placeholder for nodeExecution plugins
|
|
const placeholderInstance: PluginInstance = {
|
|
manifest,
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: this._translateService.instant(T.PLUGINS.NODE_ONLY_DESKTOP),
|
|
};
|
|
this._pluginPaths.set(manifest.id, pluginPath); // Store the path for potential reload
|
|
PluginLog.log(
|
|
`Plugin ${manifest.id} requires desktop version, creating placeholder`,
|
|
);
|
|
return placeholderInstance;
|
|
}
|
|
|
|
// Skip consent check during startup - will be checked when plugin is activated
|
|
// This prevents showing multiple dialogs at once during app startup
|
|
}
|
|
|
|
// Analyze plugin code (informational only - KISS approach)
|
|
const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest);
|
|
if (codeAnalysis.warnings.length > 0) {
|
|
PluginLog.err(`Plugin ${manifest.id} warnings:`, codeAnalysis.warnings);
|
|
}
|
|
if (codeAnalysis.info.length > 0) {
|
|
PluginLog.info(`Plugin ${manifest.id} info:`, codeAnalysis.info);
|
|
}
|
|
|
|
// If plugin is disabled, create a placeholder instance without loading code
|
|
if (!isPluginEnabled) {
|
|
const placeholderInstance: PluginInstance = {
|
|
manifest,
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: undefined,
|
|
};
|
|
this._pluginPaths.set(manifest.id, pluginPath); // Store the path for potential reload
|
|
PluginLog.log(`Plugin ${manifest.id} is disabled, skipping load`);
|
|
return placeholderInstance;
|
|
}
|
|
|
|
// Load the plugin
|
|
const baseCfg = this._getBaseCfg();
|
|
const pluginInstance = await this._pluginRunner.loadPlugin(
|
|
manifest,
|
|
pluginCode,
|
|
baseCfg,
|
|
true, // Plugin is enabled if we reach this point
|
|
);
|
|
|
|
if (pluginInstance.loaded) {
|
|
// Check if plugin is already in the list to prevent duplicates
|
|
const existingIndex = this._loadedPlugins.findIndex(
|
|
(p) => p.manifest.id === manifest.id,
|
|
);
|
|
if (existingIndex === -1) {
|
|
this._loadedPlugins.push(pluginInstance);
|
|
} else {
|
|
// Replace existing instance
|
|
this._loadedPlugins[existingIndex] = pluginInstance;
|
|
}
|
|
this._pluginPaths.set(manifest.id, pluginPath); // Store the path
|
|
|
|
// Mark plugin as enabled in memory only during startup to avoid sync conflicts
|
|
// The enabled state will be persisted later when user explicitly enables/disables plugins
|
|
this._ensurePluginEnabledInMemory(manifest.id);
|
|
|
|
await this._fireOnReadyWithCleanup(pluginInstance);
|
|
|
|
PluginLog.log(`Plugin ${manifest.id} loaded successfully`);
|
|
} else {
|
|
PluginLog.err(`Plugin ${manifest.id} failed to load:`, pluginInstance.error);
|
|
}
|
|
|
|
return pluginInstance;
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to load plugin from ${pluginPath}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private _getBaseCfg(): PluginBaseCfg {
|
|
let platform: PluginBaseCfg['platform'] = 'web';
|
|
if (IS_ELECTRON) {
|
|
platform = 'desktop';
|
|
} else if (IS_ANDROID_WEB_VIEW) {
|
|
platform = 'android';
|
|
}
|
|
|
|
const darkModeValue = this._globalThemeService.darkMode();
|
|
const isDark = darkModeValue === 'dark';
|
|
|
|
return {
|
|
theme: isDark ? 'dark' : 'light',
|
|
appVersion: environment.version,
|
|
platform,
|
|
isDev: !environment.production,
|
|
};
|
|
}
|
|
|
|
async getAllPlugins(): Promise<PluginInstance[]> {
|
|
// In lazy loading mode, return all discovered plugins
|
|
const allPlugins: PluginInstance[] = [];
|
|
|
|
for (const state of this._pluginStates().values()) {
|
|
if (state.instance) {
|
|
// Plugin is loaded, use the instance
|
|
allPlugins.push(state.instance);
|
|
} else {
|
|
// Create a placeholder instance
|
|
allPlugins.push({
|
|
manifest: state.manifest,
|
|
loaded: state.status === 'loaded',
|
|
isEnabled: state.isEnabled,
|
|
error: state.error,
|
|
});
|
|
}
|
|
}
|
|
|
|
return allPlugins;
|
|
}
|
|
|
|
getAllPluginStates(): Map<string, PluginState> {
|
|
return new Map(this._pluginStates());
|
|
}
|
|
|
|
async getAllPluginsLegacy(): Promise<PluginInstance[]> {
|
|
const loadedPlugins = [...this._loadedPlugins];
|
|
const allPluginMetadata =
|
|
await this._pluginMetaPersistenceService.getAllPluginMetadata();
|
|
|
|
// Update loaded plugins with persistence state
|
|
for (const plugin of loadedPlugins) {
|
|
const metadata = allPluginMetadata.find((data) => data.id === plugin.manifest.id);
|
|
plugin.isEnabled = metadata?.isEnabled ?? false;
|
|
}
|
|
|
|
// Add disabled plugins that aren't loaded
|
|
for (const pluginMetadata of allPluginMetadata) {
|
|
const isAlreadyLoaded = loadedPlugins.some(
|
|
(p) => p.manifest.id === pluginMetadata.id,
|
|
);
|
|
if (!isAlreadyLoaded && pluginMetadata.isEnabled === false) {
|
|
// Create minimal PluginInstance for disabled plugins
|
|
loadedPlugins.push({
|
|
manifest: {
|
|
id: pluginMetadata.id,
|
|
name: pluginMetadata.id,
|
|
version: 'unknown',
|
|
manifestVersion: 1,
|
|
minSupVersion: 'unknown',
|
|
hooks: [],
|
|
permissions: [],
|
|
type: 'standard',
|
|
},
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
return loadedPlugins;
|
|
}
|
|
|
|
getLoadedPlugins(): PluginInstance[] {
|
|
return [...this._loadedPlugins];
|
|
}
|
|
|
|
/**
|
|
* Get a loaded plugin by ID
|
|
* Returns an Observable that emits the plugin instance if found
|
|
*/
|
|
getLoadedPlugin(pluginId: string): Observable<PluginInstance | null> {
|
|
const plugin = this._loadedPlugins.find(
|
|
(p) => p.manifest.id === pluginId && p.loaded,
|
|
);
|
|
return of(plugin || null);
|
|
}
|
|
|
|
getPluginPath(pluginId: string): string | undefined {
|
|
return this._pluginPaths.get(pluginId);
|
|
}
|
|
|
|
isInitialized(): boolean {
|
|
return this._isInitialized;
|
|
}
|
|
|
|
/**
|
|
* Get index.html content for a plugin
|
|
*/
|
|
getPluginIndexHtml(pluginId: string): string | null {
|
|
return this._pluginIndexHtml.get(pluginId) || null;
|
|
}
|
|
|
|
/**
|
|
* Get SVG icon content for a plugin
|
|
*/
|
|
getPluginIcon(pluginId: string): string | null {
|
|
return this._pluginIcons.get(pluginId) || null;
|
|
}
|
|
|
|
/**
|
|
* Get reactive signal for plugin icons
|
|
*/
|
|
getPluginIconsSignal(): import('@angular/core').Signal<ReadonlyMap<string, string>> {
|
|
return this._pluginIconsSignal.asReadonly();
|
|
}
|
|
|
|
/**
|
|
* Set the active side panel plugin
|
|
*/
|
|
async setActiveSidePanelPlugin(pluginId: string | null): Promise<void> {
|
|
if (!pluginId) {
|
|
this._activeSidePanelPlugin$.next(null);
|
|
return;
|
|
}
|
|
|
|
// Check if plugin exists in states
|
|
const state = this._getPluginState(pluginId);
|
|
if (!state) {
|
|
PluginLog.err(`Plugin ${pluginId} not found`);
|
|
this._activeSidePanelPlugin$.next(null);
|
|
return;
|
|
}
|
|
|
|
// If plugin is already loaded, use it
|
|
if (state.instance && state.status === 'loaded') {
|
|
this._activeSidePanelPlugin$.next(state.instance);
|
|
return;
|
|
}
|
|
|
|
// Lazy load the plugin if needed
|
|
try {
|
|
const instance = await this.activatePlugin(pluginId);
|
|
if (instance) {
|
|
this._activeSidePanelPlugin$.next(instance);
|
|
} else {
|
|
PluginLog.err(`Failed to activate plugin ${pluginId}`);
|
|
this._activeSidePanelPlugin$.next(null);
|
|
}
|
|
} catch (error) {
|
|
PluginLog.err(`Error activating plugin ${pluginId}:`, error);
|
|
this._activeSidePanelPlugin$.next(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current active side panel plugin ID
|
|
*/
|
|
getActiveSidePanelPluginId(): string | null {
|
|
const activePlugin = this._activeSidePanelPlugin$.value;
|
|
return activePlugin?.manifest.id || null;
|
|
}
|
|
|
|
/**
|
|
* Get the base configuration for plugins
|
|
*/
|
|
async getBaseCfg(): Promise<PluginBaseCfg> {
|
|
return this._getBaseCfg();
|
|
}
|
|
|
|
/**
|
|
* Load plugin index.html content
|
|
*/
|
|
async loadPluginIndexHtml(pluginId: string): Promise<string | null> {
|
|
// First check if we already have it cached
|
|
const cached = this._pluginIndexHtml.get(pluginId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// Try to load from cache if it's an uploaded plugin
|
|
const pluginPath = this._pluginPaths.get(pluginId);
|
|
if (pluginPath?.startsWith('uploaded://')) {
|
|
const cachedPlugin = await this._pluginCacheService.getPlugin(pluginId);
|
|
if (cachedPlugin?.indexHtml) {
|
|
this._pluginIndexHtml.set(pluginId, cachedPlugin.indexHtml);
|
|
return cachedPlugin.indexHtml;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async dispatchHook(hookName: Hooks, payload?: unknown): Promise<void> {
|
|
// NOTE: these are events that the plugins might listen to. it is ok that they don't get them, if they happen
|
|
// before the plugins are loaded
|
|
if (!this._isInitialized) {
|
|
return;
|
|
}
|
|
await this._pluginHooks.dispatchHook(hookName, payload);
|
|
}
|
|
|
|
async dispatchHookToPlugin(
|
|
pluginId: string,
|
|
hookName: Hooks,
|
|
payload?: unknown,
|
|
): Promise<void> {
|
|
if (!this._isInitialized) {
|
|
return;
|
|
}
|
|
await this._pluginHooks.dispatchHookToPlugin(pluginId, hookName, payload);
|
|
}
|
|
|
|
async loadPluginFromPath(pluginPath: string): Promise<PluginInstance> {
|
|
const pluginInstance = await this._loadPlugin(pluginPath);
|
|
|
|
if (pluginInstance.loaded) {
|
|
this._loadedPlugins.push(pluginInstance);
|
|
}
|
|
|
|
return pluginInstance;
|
|
}
|
|
|
|
async loadPluginFromZip(file: File): Promise<PluginInstance> {
|
|
PluginLog.log(`Starting plugin load from ZIP: ${file.name}`);
|
|
|
|
// Import fflate dynamically for better bundle size
|
|
const { unzip } = await import('fflate');
|
|
|
|
try {
|
|
// Validate ZIP file size
|
|
if (file.size > MAX_PLUGIN_ZIP_SIZE) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.FILE_TOO_LARGE, {
|
|
maxSize: (MAX_PLUGIN_ZIP_SIZE / 1024 / 1024).toFixed(1),
|
|
fileSize: (file.size / 1024 / 1024).toFixed(1),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Convert File to Uint8Array
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const zipData = new Uint8Array(arrayBuffer);
|
|
|
|
// Extract ZIP contents
|
|
const extractedFiles = await new Promise<Record<string, Uint8Array>>(
|
|
(resolve, reject) => {
|
|
unzip(zipData, (err, files) => {
|
|
if (err) {
|
|
reject(
|
|
new Error(
|
|
this._translateService.instant(T.PLUGINS.FAILED_TO_EXTRACT_ZIP, {
|
|
error: err.message,
|
|
}),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve(files);
|
|
});
|
|
},
|
|
);
|
|
PluginLog.log({ extractedFiles });
|
|
|
|
// Find and extract manifest.json
|
|
if (!extractedFiles['manifest.json']) {
|
|
throw new Error(this._translateService.instant(T.PLUGINS.MANIFEST_NOT_FOUND));
|
|
}
|
|
|
|
// Validate manifest.json size
|
|
const manifestBytes = extractedFiles['manifest.json'];
|
|
if (manifestBytes.length > MAX_PLUGIN_MANIFEST_SIZE) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.MANIFEST_TOO_LARGE, {
|
|
maxSize: (MAX_PLUGIN_MANIFEST_SIZE / 1024).toFixed(1),
|
|
}),
|
|
);
|
|
}
|
|
|
|
const manifestText = new TextDecoder().decode(manifestBytes);
|
|
const manifest: PluginManifest = JSON.parse(manifestText);
|
|
|
|
// Validate manifest
|
|
const manifestValidation = validatePluginManifest(manifest);
|
|
if (!manifestValidation.isValid) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.VALIDATION_FAILED, {
|
|
errors: manifestValidation.errors.join(', '),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Extract index.html if it exists (optional) and iFrame is true
|
|
let indexHtml: string | null = null;
|
|
const hasIndexHtml =
|
|
manifest.iFrame === true && extractedFiles['index.html'] !== undefined;
|
|
if (hasIndexHtml) {
|
|
const indexHtmlBytes = extractedFiles['index.html'];
|
|
// Reuse the manifest size limit for index.html.
|
|
if (indexHtmlBytes.length > MAX_PLUGIN_MANIFEST_SIZE) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.INDEX_HTML_TOO_LARGE, {
|
|
maxSize: (MAX_PLUGIN_MANIFEST_SIZE / 1024).toFixed(1),
|
|
}),
|
|
);
|
|
}
|
|
indexHtml = new TextDecoder().decode(indexHtmlBytes);
|
|
}
|
|
|
|
// Extract plugin.js when available. Iframe-only plugins can omit it because
|
|
// PluginRunner auto-registers iframe menu and side-panel entries from the manifest.
|
|
let pluginCode = '';
|
|
const pluginCodeBytes = extractedFiles['plugin.js'];
|
|
if (pluginCodeBytes !== undefined) {
|
|
if (pluginCodeBytes.length > MAX_PLUGIN_CODE_SIZE) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.CODE_TOO_LARGE, {
|
|
maxSize: (MAX_PLUGIN_CODE_SIZE / 1024 / 1024).toFixed(1),
|
|
}),
|
|
);
|
|
}
|
|
pluginCode = new TextDecoder().decode(pluginCodeBytes);
|
|
} else if (!hasIndexHtml) {
|
|
throw new Error(this._translateService.instant(T.PLUGINS.PLUGIN_JS_NOT_FOUND));
|
|
} else if (!indexHtml?.trim()) {
|
|
throw new Error(this._translateService.instant(T.PLUGINS.INDEX_HTML_NOT_LOADED));
|
|
}
|
|
|
|
// Extract icon if specified in manifest
|
|
let iconContent: string | null = null;
|
|
if (manifest.icon && extractedFiles[manifest.icon]) {
|
|
const iconBytes = extractedFiles[manifest.icon];
|
|
// Reuse the manifest size limit for the SVG icon.
|
|
if (iconBytes.length > MAX_PLUGIN_MANIFEST_SIZE) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.ICON_TOO_LARGE, {
|
|
maxSize: (MAX_PLUGIN_MANIFEST_SIZE / 1024).toFixed(1),
|
|
}),
|
|
);
|
|
}
|
|
iconContent = new TextDecoder().decode(iconBytes);
|
|
// Basic SVG validation
|
|
if (!iconContent.includes('<svg') || !iconContent.includes('</svg>')) {
|
|
PluginLog.err(`Plugin icon ${manifest.icon} does not appear to be a valid SVG`);
|
|
iconContent = null;
|
|
}
|
|
}
|
|
|
|
// Extract config schema if specified in manifest
|
|
let configSchema: string | undefined;
|
|
if (manifest.jsonSchemaCfg && extractedFiles[manifest.jsonSchemaCfg]) {
|
|
const schemaBytes = extractedFiles[manifest.jsonSchemaCfg];
|
|
if (schemaBytes.length <= MAX_PLUGIN_MANIFEST_SIZE) {
|
|
configSchema = new TextDecoder().decode(schemaBytes);
|
|
} else {
|
|
PluginLog.err(
|
|
`Plugin config schema ${manifest.jsonSchemaCfg} is too large, skipping`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Analyze plugin code (informational only - KISS approach)
|
|
const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest);
|
|
if (codeAnalysis.warnings.length > 0) {
|
|
PluginLog.err(`Plugin ${manifest.id} warnings:`, codeAnalysis.warnings);
|
|
}
|
|
if (codeAnalysis.info.length > 0) {
|
|
PluginLog.info(`Plugin ${manifest.id} info:`, codeAnalysis.info);
|
|
}
|
|
|
|
// Teardown existing plugin runtime if re-uploading same ID
|
|
const existingState = this._getPluginState(manifest.id);
|
|
if (existingState) {
|
|
this._teardownPluginRuntime(manifest.id);
|
|
// Clear stale assets from previous version
|
|
this._pluginIndexHtml.delete(manifest.id);
|
|
this._pluginIcons.delete(manifest.id);
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
// Check if plugin is enabled (default to true for new uploads)
|
|
const isPluginEnabled = await this._pluginMetaPersistenceService.isPluginEnabled(
|
|
manifest.id,
|
|
);
|
|
|
|
// Create a unique path identifier for uploaded plugins
|
|
const uploadedPluginPath = `uploaded://${manifest.id}`;
|
|
|
|
// Always store plugin files in cache for later use (regardless of enabled state)
|
|
await this._pluginCacheService.storePlugin(
|
|
manifest.id,
|
|
manifestText,
|
|
pluginCode,
|
|
indexHtml || undefined,
|
|
iconContent || undefined,
|
|
undefined,
|
|
configSchema,
|
|
);
|
|
|
|
// Store index.html content if it exists
|
|
if (indexHtml) {
|
|
this._pluginIndexHtml.set(manifest.id, indexHtml);
|
|
}
|
|
|
|
// Store icon content if it exists
|
|
if (iconContent) {
|
|
this._pluginIcons.set(manifest.id, iconContent);
|
|
this._registerPluginIcon(manifest.id, iconContent);
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
// Check for dangerous permissions in web version
|
|
if (this._pluginSecurity.hasElevatedPermissions(manifest) && !IS_ELECTRON) {
|
|
// In web version, create a disabled placeholder for nodeExecution plugins
|
|
const placeholderInstance: PluginInstance = {
|
|
manifest,
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: this._translateService.instant(T.PLUGINS.NODE_ONLY_DESKTOP),
|
|
};
|
|
this._pluginPaths.set(manifest.id, uploadedPluginPath);
|
|
// Check if plugin is already in the list to prevent duplicates
|
|
const existingIndex = this._loadedPlugins.findIndex(
|
|
(p) => p.manifest.id === manifest.id,
|
|
);
|
|
if (existingIndex === -1) {
|
|
this._loadedPlugins.push(placeholderInstance);
|
|
} else {
|
|
// Replace existing instance
|
|
this._loadedPlugins[existingIndex] = placeholderInstance;
|
|
}
|
|
|
|
// Add to plugin states for UI reactivity
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'error',
|
|
path: uploadedPluginPath,
|
|
type: 'uploaded',
|
|
isEnabled: false,
|
|
error: placeholderInstance.error,
|
|
icon: iconContent || undefined,
|
|
};
|
|
this._setPluginState(manifest.id, state);
|
|
|
|
PluginLog.log(
|
|
`Uploaded plugin ${manifest.id} requires desktop version, creating placeholder`,
|
|
);
|
|
return placeholderInstance;
|
|
}
|
|
|
|
// If plugin is disabled, create a placeholder instance without loading code
|
|
if (!isPluginEnabled) {
|
|
const placeholderInstance: PluginInstance = {
|
|
manifest,
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: undefined,
|
|
};
|
|
this._pluginPaths.set(manifest.id, uploadedPluginPath);
|
|
// Check if plugin is already in the list to prevent duplicates
|
|
const existingIndexDisabled = this._loadedPlugins.findIndex(
|
|
(p) => p.manifest.id === manifest.id,
|
|
);
|
|
if (existingIndexDisabled === -1) {
|
|
this._loadedPlugins.push(placeholderInstance);
|
|
} else {
|
|
// Replace existing instance
|
|
this._loadedPlugins[existingIndexDisabled] = placeholderInstance;
|
|
}
|
|
|
|
// Add to plugin states for UI reactivity
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'not-loaded',
|
|
path: uploadedPluginPath,
|
|
type: 'uploaded',
|
|
isEnabled: false,
|
|
icon: iconContent || undefined,
|
|
};
|
|
this._setPluginState(manifest.id, state);
|
|
|
|
PluginLog.log(`Uploaded plugin ${manifest.id} is disabled, skipping load`);
|
|
return placeholderInstance;
|
|
}
|
|
|
|
// Load the plugin
|
|
const baseCfg = this._getBaseCfg();
|
|
const pluginInstance = await this._pluginRunner.loadPlugin(
|
|
manifest,
|
|
pluginCode,
|
|
baseCfg,
|
|
true, // Plugin is enabled if we reach this point
|
|
);
|
|
|
|
if (pluginInstance.loaded) {
|
|
// Check if plugin is already in the list to prevent duplicates
|
|
const existingIndexLoaded = this._loadedPlugins.findIndex(
|
|
(p) => p.manifest.id === manifest.id,
|
|
);
|
|
if (existingIndexLoaded === -1) {
|
|
this._loadedPlugins.push(pluginInstance);
|
|
} else {
|
|
// Replace existing instance
|
|
this._loadedPlugins[existingIndexLoaded] = pluginInstance;
|
|
}
|
|
this._pluginPaths.set(manifest.id, uploadedPluginPath);
|
|
|
|
// Add to plugin states for UI reactivity
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'loaded',
|
|
path: uploadedPluginPath,
|
|
type: 'uploaded',
|
|
isEnabled: true,
|
|
instance: pluginInstance,
|
|
icon: iconContent || undefined,
|
|
};
|
|
this._setPluginState(manifest.id, state);
|
|
|
|
await this._fireOnReadyWithCleanup(pluginInstance);
|
|
|
|
PluginLog.log(`Uploaded plugin ${manifest.id} loaded successfully`);
|
|
} else {
|
|
PluginLog.err(
|
|
`Uploaded plugin ${manifest.id} failed to load:`,
|
|
pluginInstance.error,
|
|
);
|
|
|
|
// Add failed plugin to states as well
|
|
const state: PluginState = {
|
|
manifest,
|
|
status: 'error',
|
|
path: uploadedPluginPath,
|
|
type: 'uploaded',
|
|
isEnabled: false,
|
|
error: pluginInstance.error,
|
|
icon: iconContent || undefined,
|
|
};
|
|
this._setPluginState(manifest.id, state);
|
|
}
|
|
|
|
return pluginInstance;
|
|
} catch (error) {
|
|
PluginLog.err('Failed to load plugin from ZIP:', error);
|
|
|
|
// Create error instance for UI display
|
|
const errorInstance: PluginInstance = {
|
|
manifest: {
|
|
id: `error-${Date.now()}`,
|
|
name: file.name.replace('.zip', ''),
|
|
version: 'unknown',
|
|
manifestVersion: 1,
|
|
minSupVersion: 'unknown',
|
|
hooks: [],
|
|
permissions: [],
|
|
type: 'standard',
|
|
},
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: this._translateService.instant(T.PLUGINS.UNKNOWN_ERROR),
|
|
};
|
|
|
|
// Still add to loaded plugins list so user can see the error
|
|
this._loadedPlugins.push(errorInstance);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async removeUploadedPlugin(pluginId: string): Promise<void> {
|
|
// Check if this plugin is active in the side panel
|
|
const activePluginId = this.getActiveSidePanelPluginId();
|
|
if (activePluginId === pluginId) {
|
|
// Close the side panel if this plugin is active
|
|
this.setActiveSidePanelPlugin(null);
|
|
}
|
|
|
|
// First disable and unload the plugin if it's currently loaded
|
|
const pluginInstance = this._loadedPlugins.find((p) => p.manifest.id === pluginId);
|
|
if (pluginInstance && pluginInstance.loaded) {
|
|
// Disable the plugin first
|
|
await this._pluginMetaPersistenceService.setPluginEnabled(pluginId, false);
|
|
|
|
// Unload and unregister the plugin
|
|
this.unloadPlugin(pluginId);
|
|
}
|
|
|
|
// Remove from cache
|
|
await this._pluginCacheService.removePlugin(pluginId);
|
|
|
|
// Remove from persistence (both user data and metadata)
|
|
await this._pluginUserPersistenceService.removePluginUserData(pluginId);
|
|
await this._pluginMetaPersistenceService.removePluginMetadata(pluginId);
|
|
|
|
// Remove from loaded plugins completely (not just unload)
|
|
const index = this._loadedPlugins.findIndex((p) => p.manifest.id === pluginId);
|
|
if (index !== -1) {
|
|
this._loadedPlugins.splice(index, 1);
|
|
}
|
|
|
|
// Remove path mapping
|
|
this._pluginPaths.delete(pluginId);
|
|
|
|
// Remove index.html content
|
|
this._pluginIndexHtml.delete(pluginId);
|
|
|
|
// Remove icon content
|
|
this._pluginIcons.delete(pluginId);
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
|
|
// Remove from plugin states
|
|
this._deletePluginState(pluginId);
|
|
|
|
PluginLog.log(`Uploaded plugin ${pluginId} removed completely`);
|
|
}
|
|
|
|
/**
|
|
* Clear all uploaded plugins from memory. Called when IndexedDB cache is cleared
|
|
* so that in-memory state matches the empty cache.
|
|
*/
|
|
clearUploadedPluginsFromMemory(): void {
|
|
const states = this._pluginStates();
|
|
const uploadedIds: string[] = [];
|
|
for (const [pluginId, state] of states.entries()) {
|
|
if (state.type === 'uploaded') {
|
|
uploadedIds.push(pluginId);
|
|
}
|
|
}
|
|
for (const pluginId of uploadedIds) {
|
|
this._teardownPluginRuntime(pluginId);
|
|
this._pluginPaths.delete(pluginId);
|
|
this._pluginIndexHtml.delete(pluginId);
|
|
this._pluginIcons.delete(pluginId);
|
|
}
|
|
// Batch-delete all uploaded plugin states in a single signal update
|
|
this._pluginStates.update((current) => {
|
|
const updated = new Map(current);
|
|
for (const pluginId of uploadedIds) {
|
|
updated.delete(pluginId);
|
|
}
|
|
return updated;
|
|
});
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
/**
|
|
* Teardown plugin runtime (hooks, runner, loaded-plugins list, side panel)
|
|
* without changing isEnabled or _pluginStates. Used for re-upload and reload.
|
|
*/
|
|
private _teardownPluginRuntime(pluginId: string): void {
|
|
// Close the side panel if this plugin is active
|
|
const activePluginId = this.getActiveSidePanelPluginId();
|
|
if (activePluginId === pluginId) {
|
|
this.setActiveSidePanelPlugin(null);
|
|
}
|
|
|
|
// Remove from loaded plugins list
|
|
const index = this._loadedPlugins.findIndex((p) => p.manifest.id === pluginId);
|
|
if (index !== -1) {
|
|
this._loadedPlugins.splice(index, 1);
|
|
}
|
|
|
|
// Unregister issue provider and sync adapter from their registries
|
|
const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
|
|
this._pluginIssueProviderRegistry.unregister(pluginId);
|
|
if (registeredKey) {
|
|
this._syncAdapterRegistry.unregister(registeredKey);
|
|
}
|
|
|
|
// Unregister hooks, translations, and unload from runner
|
|
this._pluginHooks.unregisterPluginHooks(pluginId);
|
|
this._pluginI18nService.unloadPluginTranslations(pluginId);
|
|
this._pluginRunner.unloadPlugin(pluginId);
|
|
}
|
|
|
|
unloadPlugin(pluginId: string): boolean {
|
|
// In lazy loading mode, update plugin state
|
|
const state = this._getPluginState(pluginId);
|
|
if (!state) {
|
|
return false;
|
|
}
|
|
|
|
this._teardownPluginRuntime(pluginId);
|
|
|
|
// Update state to not-loaded and disabled
|
|
const updatedState: PluginState = {
|
|
...state,
|
|
status: 'not-loaded',
|
|
instance: undefined,
|
|
isEnabled: false,
|
|
};
|
|
this._setPluginState(pluginId, updatedState);
|
|
|
|
return true;
|
|
}
|
|
|
|
async reloadPlugin(pluginId: string): Promise<boolean> {
|
|
const state = this._getPluginState(pluginId);
|
|
if (!state) {
|
|
PluginLog.err(`Cannot reload plugin ${pluginId}: not found`);
|
|
return false;
|
|
}
|
|
|
|
// Teardown runtime without disabling, so activatePlugin can re-enable
|
|
this._teardownPluginRuntime(pluginId);
|
|
this._setPluginState(pluginId, {
|
|
...state,
|
|
status: 'not-loaded',
|
|
instance: undefined,
|
|
});
|
|
|
|
const instance = await this.activatePlugin(pluginId);
|
|
return instance !== null && instance.loaded;
|
|
}
|
|
|
|
private async _loadUploadedPlugin(pluginId: string): Promise<PluginInstance> {
|
|
try {
|
|
// Use the loader service for uploaded plugins
|
|
const assets = await this._pluginLoader.loadUploadedPluginAssets(pluginId);
|
|
const { manifest, code: pluginCode, indexHtml, icon, translations } = assets;
|
|
|
|
// Store assets if loaded
|
|
if (indexHtml) {
|
|
this._pluginIndexHtml.set(manifest.id, indexHtml);
|
|
}
|
|
if (icon) {
|
|
this._pluginIcons.set(manifest.id, icon);
|
|
this._registerPluginIcon(manifest.id, icon);
|
|
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
|
}
|
|
|
|
// Load translations into i18n service
|
|
if (translations && Object.keys(translations).length > 0) {
|
|
this._pluginI18nService.loadPluginTranslationsFromContent(
|
|
manifest.id,
|
|
translations,
|
|
);
|
|
}
|
|
|
|
// Validate manifest
|
|
const manifestValidation = validatePluginManifest(manifest);
|
|
if (!manifestValidation.isValid) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.VALIDATION_FAILED, {
|
|
errors: manifestValidation.errors.join(', '),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Analyze plugin code (informational only - KISS approach)
|
|
const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest);
|
|
if (codeAnalysis.warnings.length > 0) {
|
|
PluginLog.err(`Plugin ${manifest.id} warnings:`, codeAnalysis.warnings);
|
|
}
|
|
if (codeAnalysis.info.length > 0) {
|
|
PluginLog.info(`Plugin ${manifest.id} info:`, codeAnalysis.info);
|
|
}
|
|
|
|
// Check if plugin is enabled
|
|
const isPluginEnabled = await this._pluginMetaPersistenceService.isPluginEnabled(
|
|
manifest.id,
|
|
);
|
|
|
|
// If plugin is disabled, create a placeholder instance without loading code
|
|
if (!isPluginEnabled) {
|
|
const placeholderInstance: PluginInstance = {
|
|
manifest,
|
|
loaded: false,
|
|
isEnabled: false,
|
|
error: undefined,
|
|
};
|
|
PluginLog.log(`Uploaded plugin ${manifest.id} is disabled, skipping reload`);
|
|
return placeholderInstance;
|
|
}
|
|
|
|
// Load the plugin
|
|
const baseCfg = this._getBaseCfg();
|
|
const pluginInstance = await this._pluginRunner.loadPlugin(
|
|
manifest,
|
|
pluginCode,
|
|
baseCfg,
|
|
true, // Plugin is enabled if we reach this point
|
|
);
|
|
|
|
if (pluginInstance.loaded) {
|
|
// Check if plugin is already in the list to prevent duplicates
|
|
const existingIndex = this._loadedPlugins.findIndex(
|
|
(p) => p.manifest.id === manifest.id,
|
|
);
|
|
if (existingIndex === -1) {
|
|
this._loadedPlugins.push(pluginInstance);
|
|
} else {
|
|
// Replace existing instance
|
|
this._loadedPlugins[existingIndex] = pluginInstance;
|
|
}
|
|
await this._fireOnReadyWithCleanup(pluginInstance);
|
|
PluginLog.log(`Uploaded plugin ${manifest.id} reloaded successfully`);
|
|
} else {
|
|
PluginLog.err(
|
|
`Uploaded plugin ${manifest.id} failed to reload:`,
|
|
pluginInstance.error,
|
|
);
|
|
}
|
|
|
|
return pluginInstance;
|
|
} catch (error) {
|
|
PluginLog.err(`Failed to reload uploaded plugin ${pluginId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a plugin requires and has consent for Node.js execution
|
|
*/
|
|
async checkNodeExecutionPermission(manifest: PluginManifest): Promise<boolean> {
|
|
// Check if plugin has nodeExecution permission
|
|
if (!manifest.permissions?.includes('nodeExecution')) {
|
|
return true; // No node execution permission needed
|
|
}
|
|
|
|
// Only check consent in Electron environment
|
|
if (!IS_ELECTRON) {
|
|
PluginLog.err(
|
|
`Plugin ${manifest.id} requires nodeExecution permission which is not available in web environment`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return this._getNodeExecutionConsent(manifest);
|
|
}
|
|
|
|
/**
|
|
* Ping the Node.js IPC bridge with a trivial no-op script.
|
|
* Retries up to 3 times (delays: 1s, 2s) before throwing.
|
|
* Throws if the bridge is unavailable after all retries.
|
|
*/
|
|
private async _pingNodeBridge(manifest: PluginManifest): Promise<void> {
|
|
const ok = await pingWithRetry(() => this._pluginRunner.pingNodeBridge(manifest.id));
|
|
if (!ok) {
|
|
throw new Error(
|
|
this._translateService.instant(T.PLUGINS.NODE_EXECUTION_BRIDGE_UNAVAILABLE),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check node execution permission on startup (uses stored consent)
|
|
*/
|
|
private async _checkNodeExecutionPermissionForStartup(
|
|
manifest: PluginManifest,
|
|
): Promise<boolean> {
|
|
// Check if plugin has nodeExecution permission
|
|
if (!manifest.permissions?.includes('nodeExecution')) {
|
|
return true; // No node execution permission needed
|
|
}
|
|
|
|
// Only check consent in Electron environment
|
|
if (!IS_ELECTRON) {
|
|
console.warn(
|
|
`Plugin ${manifest.id} requires nodeExecution permission which is not available in web environment`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// On startup, use stored consent if available
|
|
const storedConsent =
|
|
await this._pluginMetaPersistenceService.getNodeExecutionConsent(manifest.id);
|
|
|
|
// Only allow if consent was explicitly granted and stored
|
|
return storedConsent === true;
|
|
}
|
|
|
|
/**
|
|
* Get consent for Node.js execution permissions (KISS approach)
|
|
*/
|
|
private async _getNodeExecutionConsent(manifest: PluginManifest): Promise<boolean> {
|
|
// Check if we should pre-check the "remember" checkbox based on previous consent
|
|
const previousConsent =
|
|
await this._pluginMetaPersistenceService.getNodeExecutionConsent(manifest.id);
|
|
|
|
// Always show dialog for nodeExecution permission
|
|
const result = (await this._dialog
|
|
.open(PluginNodeConsentDialogComponent, {
|
|
data: {
|
|
manifest,
|
|
rememberChoice: previousConsent === true, // Pre-check if previously consented
|
|
} as PluginNodeConsentDialogData,
|
|
disableClose: false,
|
|
width: '500px',
|
|
})
|
|
.afterClosed()
|
|
.pipe(first())
|
|
.toPromise()) as NodeExecutionConsentDialogResult | undefined;
|
|
|
|
const decision = decideNodeExecutionConsent(result);
|
|
await this._pluginMetaPersistenceService.setNodeExecutionConsent(
|
|
manifest.id,
|
|
decision.consentToStore,
|
|
);
|
|
return decision.granted;
|
|
}
|
|
|
|
/**
|
|
* Clean up all resources when service is destroyed
|
|
*/
|
|
/**
|
|
* Ensure plugin is marked as enabled in memory only during startup.
|
|
* This avoids pfapi writes during initialization that could cause sync conflicts.
|
|
*/
|
|
private _ensurePluginEnabledInMemory(pluginId: string): void {
|
|
// We only need to track this in memory for startup purposes
|
|
// The actual persistence will happen when user explicitly enables/disables plugins
|
|
PluginLog.log(
|
|
`Plugin ${pluginId} marked as enabled in memory (no pfapi write during startup)`,
|
|
);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
PluginLog.log('PluginService: Cleaning up all resources');
|
|
|
|
// Complete the side panel subject
|
|
this._activeSidePanelPlugin$.complete();
|
|
|
|
// Unload all plugins first
|
|
const pluginIds = [...this._loadedPlugins.map((p) => p.manifest.id)];
|
|
pluginIds.forEach((pluginId) => {
|
|
try {
|
|
this.unloadPlugin(pluginId);
|
|
} catch (error) {
|
|
PluginLog.err(`Error unloading plugin ${pluginId} during cleanup:`, error);
|
|
}
|
|
});
|
|
|
|
// Clear all internal maps
|
|
this._pluginPaths.clear();
|
|
this._pluginIndexHtml.clear();
|
|
this._pluginIcons.clear();
|
|
this._loadedPlugins = [];
|
|
|
|
// Clean up any remaining resources
|
|
this._cleanupService.cleanupAll();
|
|
|
|
// Clear loader caches
|
|
this._pluginLoader.clearAllCaches();
|
|
|
|
PluginLog.log('PluginService: Cleanup complete');
|
|
}
|
|
}
|