super-productivity/src/app/plugins/plugin.service.ts
Johannes Millan 3c69aa961e
feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes (#7805)
* 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.
2026-05-26 23:41:01 +02:00

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');
}
}