From 24fced4617c8cc5915e8dbee96ce5d99439d654b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 10 Jul 2025 14:58:44 +0200 Subject: [PATCH] feat(plugins): update plugin infrastructure and cleanup --- packages/plugin-api/package.json | 2 + packages/plugin-api/src/index.ts | 33 ---- packages/plugin-api/tsconfig.json | 1 - src/app/api/batch-update-types.ts | 74 -------- src/app/plugins/plugin-api-extensions.d.ts | 11 -- src/app/plugins/plugin-api.ts | 35 ++-- src/app/plugins/plugin-bridge.service.ts | 177 +++--------------- src/app/plugins/plugin-cleanup.service.ts | 4 + src/app/plugins/plugin-hooks.effects.ts | 32 +++- src/app/plugins/plugin-hooks.ts | 10 +- src/app/plugins/plugin.service.ts | 86 +++++++-- .../ui/plugin-index/plugin-index.component.ts | 18 +- .../plugin-management.component.ts | 5 +- ...in-node-consent-dialog-simple.component.ts | 14 +- src/app/plugins/util/plugin-iframe.util.ts | 61 +++--- .../root-store/meta/task-shared.actions.ts | 2 +- src/index.html | 2 +- 17 files changed, 227 insertions(+), 340 deletions(-) delete mode 100644 src/app/api/batch-update-types.ts delete mode 100644 src/app/plugins/plugin-api-extensions.d.ts diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index e080f88173..83f1e99b64 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -2,6 +2,7 @@ "name": "@super-productivity/plugin-api", "version": "1.0.1", "description": "TypeScript definitions for Super Productivity plugin development", + "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", @@ -31,6 +32,7 @@ }, "homepage": "https://github.com/johannesjo/super-productivity#readme", "files": [ + "dist/**/*.js", "dist/**/*.d.ts", "README.md" ], diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index c023100c59..4dc1636a36 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -1,35 +1,2 @@ -// Super Productivity Plugin API Types // Official TypeScript definitions for developing Super Productivity plugins - export * from './types'; - -// Re-export commonly used types with cleaner names -export type { - PluginAPI, - PluginManifest, - TaskData, - ProjectData, - TagData, - PluginCreateTaskData, - PluginBaseCfg, - DialogCfg, - DialogButtonCfg, - SnackCfg, - SnackCfgLimited, - NotifyCfg, - PluginMenuEntryCfg, - PluginShortcutCfg, - PluginHeaderBtnCfg, - PluginHookHandler, - PluginInstance, - PluginHookHandlerRegistration, - TaskCopy, - ProjectCopy, - TagCopy, -} from './types'; - -// Re-export enums as values (not just types) so they can be used -export { PluginHooks, type Hooks } from './types'; - -// Export app-specific types that extend the plugin-api versions -export type { PluginMenuEntryCfg as PluginMenuEntryCfgApp } from './types'; diff --git a/packages/plugin-api/tsconfig.json b/packages/plugin-api/tsconfig.json index fa984c2d44..2cc7b298fe 100644 --- a/packages/plugin-api/tsconfig.json +++ b/packages/plugin-api/tsconfig.json @@ -4,7 +4,6 @@ "module": "commonjs", "lib": ["ES2020", "DOM"], "declaration": true, - "emitDeclarationOnly": true, "outDir": "./dist", "rootDir": "./src", "strict": true, diff --git a/src/app/api/batch-update-types.ts b/src/app/api/batch-update-types.ts deleted file mode 100644 index 71606070c1..0000000000 --- a/src/app/api/batch-update-types.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Types for the batchUpdateForProject PluginAPI method - -export interface BatchTaskCreate { - type: 'create'; - tempId: string; // Temporary ID to reference in other operations - data: { - title: string; - notes?: string; - isDone?: boolean; - parentId?: string | null; // Can reference tempId or existing task ID - timeEstimate?: number; - }; -} - -export interface BatchTaskUpdate { - type: 'update'; - taskId: string; // Existing task ID - updates: { - title?: string; - notes?: string; - isDone?: boolean; - parentId?: string | null; - timeEstimate?: number; - }; -} - -export interface BatchTaskDelete { - type: 'delete'; - taskId: string; // Existing task ID -} - -export interface BatchTaskReorder { - type: 'reorder'; - taskIds: string[]; // Can include tempIds for newly created tasks -} - -export type BatchOperation = - | BatchTaskCreate - | BatchTaskUpdate - | BatchTaskDelete - | BatchTaskReorder; - -export interface BatchUpdateRequest { - projectId: string; - operations: BatchOperation[]; -} - -export interface BatchUpdateResult { - success: boolean; - // Map temporary IDs to actual created task IDs - createdTaskIds: { [tempId: string]: string }; - errors?: BatchUpdateError[]; -} - -export interface BatchUpdateError { - operationIndex: number; - type: - | 'VALIDATION_ERROR' - | 'CIRCULAR_DEPENDENCY' - | 'TASK_NOT_FOUND' - | 'OUTSIDE_PROJECT' - | 'UNKNOWN'; - message: string; -} - -// Validation rules for batch updates -export interface BatchUpdateValidation { - // Maximum depth of task nesting - maxNestingDepth?: number; - // Whether to allow tasks to be moved outside the project - enforceProjectBoundary?: boolean; - // Whether to validate circular dependencies - checkCircularDependencies?: boolean; -} diff --git a/src/app/plugins/plugin-api-extensions.d.ts b/src/app/plugins/plugin-api-extensions.d.ts deleted file mode 100644 index db15c2f6f2..0000000000 --- a/src/app/plugins/plugin-api-extensions.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BatchUpdateRequest, BatchUpdateResult } from '../api/batch-update-types'; - -// Extend the PluginAPI interface from @super-productivity/plugin-api -declare module '@super-productivity/plugin-api' { - interface PluginAPI { - batchUpdateForProject(request: BatchUpdateRequest): Promise; - } -} - -// Re-export types for convenience -export * from '../api/batch-update-types'; diff --git a/src/app/plugins/plugin-api.ts b/src/app/plugins/plugin-api.ts index a2c576cf3f..122cdcecda 100644 --- a/src/app/plugins/plugin-api.ts +++ b/src/app/plugins/plugin-api.ts @@ -1,34 +1,35 @@ import { - PluginCreateTaskData, + BatchUpdateRequest, + BatchUpdateResult, DialogCfg, Hooks, NotifyCfg, + PluginAPI as PluginAPIInterface, PluginBaseCfg, + PluginCreateTaskData, + PluginHeaderBtnCfg, PluginHookHandler, PluginHooks, + PluginManifest, PluginMenuEntryCfg, - PluginShortcutCfg, - PluginHeaderBtnCfg, PluginNodeScriptRequest, PluginNodeScriptResult, + PluginShortcutCfg, PluginSidePanelBtnCfg, - Task, Project, - Tag, SnackCfg, - PluginAPI as PluginAPIInterface, - PluginManifest, + Tag, + Task, } from '@super-productivity/plugin-api'; import { PluginBridgeService } from './plugin-bridge.service'; import { - taskCopyToTaskData, projectCopyToProjectData, - tagCopyToTagData, - taskDataToPartialTaskCopy, projectDataToPartialProjectCopy, + tagCopyToTagData, tagDataToPartialTagCopy, + taskCopyToTaskData, + taskDataToPartialTaskCopy, } from './plugin-api-mapper'; -import { BatchUpdateRequest } from '../api/batch-update-types'; /** * PluginAPI implementation that uses direct bridge service injection @@ -36,7 +37,7 @@ import { BatchUpdateRequest } from '../api/batch-update-types'; */ export class PluginAPI implements PluginAPIInterface { readonly Hooks = PluginHooks; - private _hookHandlers = new Map>>(); + private _hookHandlers = new Map>>>(); private _headerButtons: Array = []; private _menuEntries: Array = []; private _shortcuts: Array = []; @@ -67,7 +68,7 @@ export class PluginAPI implements PluginAPIInterface { } } - registerHook(hook: Hooks, fn: PluginHookHandler): void { + registerHook(hook: T, fn: PluginHookHandler): void { if (!this._hookHandlers.has(this._pluginId)) { this._hookHandlers.set(this._pluginId, new Map()); } @@ -77,11 +78,11 @@ export class PluginAPI implements PluginAPIInterface { pluginHooks.set(hook, []); } - pluginHooks.get(hook)!.push(fn); + pluginHooks.get(hook)!.push(fn as PluginHookHandler); console.log(`Plugin ${this._pluginId} registered hook: ${hook}`); // Register hook with bridge - this._pluginBridge.registerHook(this._pluginId, hook, fn); + this._pluginBridge.registerHook(this._pluginId, hook, fn as PluginHookHandler); } registerHeaderButton(headerBtnCfg: PluginHeaderBtnCfg): void { @@ -215,12 +216,12 @@ export class PluginAPI implements PluginAPIInterface { return this._pluginBridge.reorderTasks(taskIds, contextId, contextType); } - async batchUpdateForProject(request: unknown): Promise { + async batchUpdateForProject(request: BatchUpdateRequest): Promise { console.log( `Plugin ${this._pluginId} requested batch update for project ${(request as { projectId: string }).projectId}`, request, ); - return this._pluginBridge.batchUpdateForProject(request as BatchUpdateRequest); + return this._pluginBridge.batchUpdateForProject(request); } showSnack(snackCfg: SnackCfg): void { diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index 63dee0833e..0dcc64ff10 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -17,20 +17,14 @@ import { PluginNodeScriptRequest, PluginNodeScriptResult, } from './plugin-api.model'; + import { + SnackCfg, + PluginManifest, BatchUpdateRequest, BatchUpdateResult, - BatchUpdateError, - BatchOperation, BatchTaskCreate, - BatchTaskUpdate, - BatchTaskDelete, - BatchTaskReorder, -} from '../api/batch-update-types'; -import { Task as TaskCopy } from '../features/tasks/task.model'; -import { Project as ProjectCopy } from '../features/project/project.model'; -import { Tag as TagCopy } from '../features/tag/tag.model'; -import { SnackCfg, PluginManifest } from '@super-productivity/plugin-api'; +} from '@super-productivity/plugin-api'; import { snackCfgToSnackParams } from './plugin-api-mapper'; import { PluginHooksService } from './plugin-hooks'; import { TaskService } from '../features/tasks/task.service'; @@ -52,6 +46,9 @@ import { isAllowedPluginAction } from './allowed-plugin-actions.const'; import { TranslateService } from '@ngx-translate/core'; import { T } from '../t.const'; import { SyncWrapperService } from '../imex/sync/sync-wrapper.service'; +import { TaskCopy } from '../features/tasks/task.model'; +import { ProjectCopy } from '../features/project/project.model'; +import { TagCopy } from '../features/tag/tag.model'; /** * PluginBridge acts as an intermediary layer between plugins and the main application services. @@ -549,58 +546,15 @@ export class PluginBridgeService implements OnDestroy { } /** - * Batch update tasks for a project with validation - * Allows multiple operations to be performed atomically with proper validation + * Batch update tasks for a project + * Only generate IDs here - let the reducer handle all validation */ async batchUpdateForProject(request: BatchUpdateRequest): Promise { typia.assert(request); - const errors: BatchUpdateError[] = []; - const createdTaskIds: { [tempId: string]: string } = {}; - - // Validate project exists - const project = await this._projectService - .getByIdOnce$(request.projectId) - .pipe(first()) - .toPromise(); - - if (!project) { - throw new Error( - this._translateService.instant(T.PLUGINS.PROJECT_NOT_FOUND, { - contextId: request.projectId, - }), - ); - } - - // Get all current tasks for validation - const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise(); - const projectTasks = allTasks?.filter((t) => t.projectId === request.projectId) || []; - - // Validate operations - for (let i = 0; i < request.operations.length; i++) { - const op = request.operations[i]; - const validationError = this._validateBatchOperation( - op, - request.projectId, - projectTasks, - createdTaskIds, - i, - ); - if (validationError) { - errors.push(validationError); - } - } - - // If there are validation errors, return early - if (errors.length > 0) { - return { - success: false, - createdTaskIds: {}, - errors, - }; - } - // Generate IDs for all create operations + // We need to do this here so we can return them to the plugin immediately + const createdTaskIds: { [tempId: string]: string } = {}; request.operations.forEach((op) => { if (op.type === 'create') { const createOp = op as BatchTaskCreate; @@ -608,98 +562,21 @@ export class PluginBridgeService implements OnDestroy { } }); - // Dispatch the batch update action - try { - this._store.dispatch( - TaskSharedActions.batchUpdateForProject({ - projectId: request.projectId, - operations: request.operations, - createdTaskIds, - }), - ); - - console.log('PluginBridge: Batch update dispatched successfully', { + // Dispatch the batch update action - let the reducer handle all validation + this._store.dispatch( + TaskSharedActions.batchUpdateForProject({ projectId: request.projectId, - operationCount: request.operations.length, - createdTasks: Object.keys(createdTaskIds).length, - }); - - return { - success: true, + operations: request.operations, createdTaskIds, - }; - } catch (error) { - console.error('PluginBridge: Batch update failed', error); - throw error; - } - } + }), + ); - private _validateBatchOperation( - op: BatchOperation, - projectId: string, - projectTasks: TaskCopy[], - createdTaskIds: { [tempId: string]: string }, - operationIndex: number, - ): BatchUpdateError | null { - switch (op.type) { - case 'create': - const createOp = op as BatchTaskCreate; - // Check for circular dependencies - if (createOp.data.parentId === createOp.tempId) { - return { - operationIndex, - type: 'CIRCULAR_DEPENDENCY', - message: 'Task cannot be its own parent', - }; - } - // Basic validation for required fields - if (!createOp.data.title || createOp.data.title.trim() === '') { - return { - operationIndex, - type: 'VALIDATION_ERROR', - message: 'Task title is required', - }; - } - break; - - case 'update': - const updateOp = op as BatchTaskUpdate; - const taskToUpdate = projectTasks.find((t) => t.id === updateOp.taskId); - if (!taskToUpdate) { - return { - operationIndex, - type: 'TASK_NOT_FOUND', - message: `Task ${updateOp.taskId} not found in project`, - }; - } - break; - - case 'delete': - const deleteOp = op as BatchTaskDelete; - const taskToDelete = projectTasks.find((t) => t.id === deleteOp.taskId); - if (!taskToDelete) { - return { - operationIndex, - type: 'TASK_NOT_FOUND', - message: `Task ${deleteOp.taskId} not found in project`, - }; - } - break; - - case 'reorder': - // Basic validation - the reducer will handle the actual reordering - const reorderOp = op as BatchTaskReorder; - if (!reorderOp.taskIds || reorderOp.taskIds.length === 0) { - return { - operationIndex, - type: 'VALIDATION_ERROR', - message: 'Reorder operation requires at least one task ID', - }; - } - break; - } - - return null; + // Return the generated IDs immediately + // The reducer will validate everything including project existence + return { + success: true, + createdTaskIds, + }; } /** @@ -748,10 +625,14 @@ export class PluginBridgeService implements OnDestroy { /** * Register a hook handler for a plugin */ - registerHook(pluginId: string, hook: Hooks, handler: PluginHookHandler): void { + registerHook( + pluginId: string, + hook: T, + handler: PluginHookHandler, + ): void { typia.assert(pluginId); typia.assert(hook); - typia.assert(handler); + // Note: Can't assert generic function type with typia this._pluginHooksService.registerHookHandler(pluginId, hook, handler); } diff --git a/src/app/plugins/plugin-cleanup.service.ts b/src/app/plugins/plugin-cleanup.service.ts index d2133c1c02..a7fcd01aad 100644 --- a/src/app/plugins/plugin-cleanup.service.ts +++ b/src/app/plugins/plugin-cleanup.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { cleanupAllPluginIframeUrls } from './util/plugin-iframe.util'; /** * Simplified cleanup service following KISS principles. @@ -33,5 +34,8 @@ export class PluginCleanupService { cleanupAll(): void { // Just clear all references - let Angular manage iframe DOM lifecycle this._pluginIframes.clear(); + + // Clean up all blob URLs to prevent memory leaks + cleanupAllPluginIframeUrls(); } } diff --git a/src/app/plugins/plugin-hooks.effects.ts b/src/app/plugins/plugin-hooks.effects.ts index 920c4ad352..7760d06084 100644 --- a/src/app/plugins/plugin-hooks.effects.ts +++ b/src/app/plugins/plugin-hooks.effects.ts @@ -14,9 +14,24 @@ import { PluginService } from './plugin.service'; import { PluginHooks } from './plugin-api.model'; import { setActiveWorkContext } from '../features/work-context/store/work-context.actions'; import { TaskSharedActions } from '../root-store/meta/task-shared.actions'; -import { setCurrentTask, unsetCurrentTask } from '../features/tasks/store/task.actions'; +import { + setCurrentTask, + unsetCurrentTask, + moveSubTask, + moveSubTaskUp, + moveSubTaskDown, + moveSubTaskToTop, + moveSubTaskToBottom, +} from '../features/tasks/store/task.actions'; import * as projectActions from '../features/project/store/project.actions'; import { updateProject } from '../features/project/store/project.actions'; +import { + moveTaskDownInTodayList, + moveTaskInTodayList, + moveTaskToBottomInTodayList, + moveTaskToTopInTodayList, + moveTaskUpInTodayList, +} from '../features/work-context/store/work-context-meta.actions'; @Injectable() export class PluginHooksEffects { @@ -152,7 +167,7 @@ export class PluginHooksEffects { { dispatch: false }, ); - // Trigger for ANY task update (add, update, delete) + // Trigger for ANY task update (add, update, delete, move subtasks) anyTaskUpdate$ = createEffect( () => this.actions$.pipe( @@ -161,6 +176,12 @@ export class PluginHooksEffects { TaskSharedActions.updateTask, TaskSharedActions.deleteTask, TaskSharedActions.deleteTasks, + // Include subtask move actions + moveSubTask, + moveSubTaskUp, + moveSubTaskDown, + moveSubTaskToTop, + moveSubTaskToBottom, ), withLatestFrom(this.store.pipe(select(selectTaskFeatureState))), tap(([action, taskState]) => { @@ -196,6 +217,13 @@ export class PluginHooksEffects { projectActions.moveProjectTaskToBacklogList, projectActions.moveProjectTaskToRegularList, projectActions.moveAllProjectBacklogTasksToRegularList, + + // cross model + moveTaskInTodayList, + moveTaskUpInTodayList, + moveTaskDownInTodayList, + moveTaskToTopInTodayList, + moveTaskToBottomInTodayList, ), withLatestFrom(this.store.pipe(select(selectProjectFeatureState))), tap(([action, projectState]) => { diff --git a/src/app/plugins/plugin-hooks.ts b/src/app/plugins/plugin-hooks.ts index a3c81af3d7..c29ea40fcf 100644 --- a/src/app/plugins/plugin-hooks.ts +++ b/src/app/plugins/plugin-hooks.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Hooks, PluginHookHandler } from './plugin-api.model'; +import { Hooks, PluginHookHandler } from '@super-productivity/plugin-api'; /** * Simplified plugin hooks service following KISS principles. @@ -9,12 +9,16 @@ import { Hooks, PluginHookHandler } from './plugin-api.model'; providedIn: 'root', }) export class PluginHooksService { - private _handlers = new Map>(); + private _handlers = new Map>>(); /** * Register a hook handler */ - registerHookHandler(pluginId: string, hook: Hooks, handler: PluginHookHandler): void { + registerHookHandler( + pluginId: string, + hook: T, + handler: PluginHookHandler, + ): void { if (!this._handlers.has(hook)) { this._handlers.set(hook, new Map()); } diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index 408eb882d6..40ada2bba5 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -223,8 +223,12 @@ export class PluginService implements OnDestroy { /** * Activate a plugin (load it if not already loaded) + * @param isManualActivation - true when user manually enables plugin, false on startup */ - async activatePlugin(pluginId: string): Promise { + async activatePlugin( + pluginId: string, + isManualActivation: boolean = false, + ): Promise { const state = this._pluginStates.get(pluginId); if (!state) { console.error(`Plugin ${pluginId} not found`); @@ -253,6 +257,28 @@ export class PluginService implements OnDestroy { return updatedState?.instance || null; } + // Only check for permission if plugin is actually enabled + if (state.isEnabled) { + // Only check permission on startup - manual activation already checked in UI + if (!isManualActivation) { + const hasConsent = await this._checkNodeExecutionPermissionForStartup( + state.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 state.status = 'loading'; this._updatePluginStates(); @@ -407,15 +433,8 @@ export class PluginService implements OnDestroy { return placeholderInstance; } - // Only check for consent if plugin is enabled - if (isPluginEnabled) { - const hasConsent = await this._getNodeExecutionConsent(manifest); - if (!hasConsent) { - throw new Error( - this._translateService.instant(T.PLUGINS.USER_DECLINED_NODE_PERMISSION), - ); - } - } + // 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) @@ -1228,22 +1247,47 @@ export class PluginService implements OnDestroy { return this._getNodeExecutionConsent(manifest); } + /** + * Check node execution permission on startup (uses stored consent) + */ + private async _checkNodeExecutionPermissionForStartup( + manifest: PluginManifest, + ): Promise { + // 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 { - // Check if consent was already given and stored - const storedConsent = + // Check if we should pre-check the "remember" checkbox based on previous consent + const previousConsent = await this._pluginMetaPersistenceService.getNodeExecutionConsent(manifest.id); - if (storedConsent === true) { - return true; - } - // Single, simple consent dialog + // 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', @@ -1259,10 +1303,20 @@ export class PluginService implements OnDestroy { setTimeout(() => { this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, true); }, 5000); + } else { + // User unchecked remember - remove stored consent + setTimeout(() => { + this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, false); + }, 5000); } return true; } + // User denied permission - remove any stored consent + setTimeout(() => { + this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, false); + }, 5000); + return false; } diff --git a/src/app/plugins/ui/plugin-index/plugin-index.component.ts b/src/app/plugins/ui/plugin-index/plugin-index.component.ts index 640b5aac15..ebcaa0335a 100644 --- a/src/app/plugins/ui/plugin-index/plugin-index.component.ts +++ b/src/app/plugins/ui/plugin-index/plugin-index.component.ts @@ -20,6 +20,7 @@ import { PluginIframeConfig, createPluginIframeUrl, handlePluginMessage, + cleanupPluginIframeUrl, } from '../../util/plugin-iframe.util'; import { CommonModule } from '@angular/common'; import { MatButton } from '@angular/material/button'; @@ -91,6 +92,7 @@ export class PluginIndexComponent implements OnInit, OnDestroy { private _messageListener?: EventListener; private _routeSubscription?: Subscription; + private _currentIframeUrl: string | null = null; async ngOnInit(): Promise { // If directPluginId is provided, load that plugin directly @@ -233,9 +235,12 @@ export class PluginIndexComponent implements OnInit, OnDestroy { boundMethods: this._pluginBridge.createBoundMethods(pluginId, plugin.manifest), }; - // Create iframe URL + // Create iframe URL using blob URL const iframeUrl = createPluginIframeUrl(config); + // Store the URL for cleanup + this._currentIframeUrl = iframeUrl; + // Store message handler for cleanup this._messageListener = async (event: Event) => { await handlePluginMessage(event as MessageEvent, config); @@ -248,7 +253,9 @@ export class PluginIndexComponent implements OnInit, OnDestroy { const safeUrl = this._sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); console.log( `Setting iframe src for plugin ${pluginId}:`, - iframeUrl.substring(0, 100) + '...', + iframeUrl.startsWith('blob:') + ? `blob:${iframeUrl.split(':')[1].substring(0, 20)}...` + : iframeUrl.substring(0, 100) + '...', ); this.iframeSrc.set(safeUrl); this.isLoading.set(false); @@ -266,6 +273,13 @@ export class PluginIndexComponent implements OnInit, OnDestroy { console.log(`Removed message listener for plugin: ${currentPluginId}`); } + // Cleanup blob URL if it exists + if (this._currentIframeUrl) { + cleanupPluginIframeUrl(this._currentIframeUrl); + console.log(`Cleaned up blob URL for plugin: ${currentPluginId}`); + this._currentIframeUrl = null; + } + // Clear iframe reference from cleanup service (but don't remove from DOM) if (currentPluginId) { this._cleanupService.cleanupPlugin(currentPluginId); diff --git a/src/app/plugins/ui/plugin-management/plugin-management.component.ts b/src/app/plugins/ui/plugin-management/plugin-management.component.ts index 3f1b60f08a..05c12dac0c 100644 --- a/src/app/plugins/ui/plugin-management/plugin-management.component.ts +++ b/src/app/plugins/ui/plugin-management/plugin-management.component.ts @@ -155,14 +155,15 @@ export class PluginManagementComponent implements OnInit { return; } - // Set plugin as enabled in persistence + // Set plugin as enabled in persistence ONLY after consent is granted await this._pluginMetaPersistenceService.setPluginEnabled(plugin.manifest.id, true); // Update the plugin state immediately plugin.isEnabled = true; // Activate the plugin (lazy load if needed) - const instance = await this._pluginService.activatePlugin(plugin.manifest.id); + // Pass true to indicate this is a manual activation from UI + const instance = await this._pluginService.activatePlugin(plugin.manifest.id, true); if (instance) { console.log('Plugin activated successfully:', plugin.manifest.id); } diff --git a/src/app/plugins/ui/plugin-node-consent-dialog/plugin-node-consent-dialog-simple.component.ts b/src/app/plugins/ui/plugin-node-consent-dialog/plugin-node-consent-dialog-simple.component.ts index 7c0f051756..b9903b32e3 100644 --- a/src/app/plugins/ui/plugin-node-consent-dialog/plugin-node-consent-dialog-simple.component.ts +++ b/src/app/plugins/ui/plugin-node-consent-dialog/plugin-node-consent-dialog-simple.component.ts @@ -15,6 +15,7 @@ import { T } from '../../../t.const'; export interface PluginNodeConsentDialogData { manifest: PluginManifest; + rememberChoice?: boolean; } @Component({ @@ -89,6 +90,10 @@ export interface PluginNodeConsentDialogData { display: block; } + ul li { + font-weight: bold; + } + mat-dialog-content { min-width: 400px; max-width: 500px; @@ -124,7 +129,7 @@ export interface PluginNodeConsentDialogData { display: flex; align-items: center; gap: 8px; - background: #e3f2fd; + border: 1px solid var(--extra-border-color); padding: 12px; border-radius: 4px; margin: 16px 0; @@ -154,6 +159,13 @@ export class PluginNodeConsentDialogComponent { T = T; rememberChoice = false; + constructor() { + // Pre-check the remember checkbox if provided in data + if (this.data.rememberChoice !== undefined) { + this.rememberChoice = this.data.rememberChoice; + } + } + onConfirm(): void { this.dialogRef.close({ granted: true, remember: this.rememberChoice }); } diff --git a/src/app/plugins/util/plugin-iframe.util.ts b/src/app/plugins/util/plugin-iframe.util.ts index 29e01922d6..a75c67bc35 100644 --- a/src/app/plugins/util/plugin-iframe.util.ts +++ b/src/app/plugins/util/plugin-iframe.util.ts @@ -1,36 +1,12 @@ import { PluginBridgeService } from '../plugin-bridge.service'; import { PluginBaseCfg, PluginManifest } from '../plugin-api.model'; +import { PluginIframeMessageType } from '@super-productivity/plugin-api'; /** * Simplified plugin iframe utilities following KISS principles. * One way to do things, no complex options. */ -/** - * Enum for plugin iframe message types - */ -export enum PluginIframeMessageType { - // API communication - API_CALL = 'PLUGIN_API_CALL', - API_RESPONSE = 'PLUGIN_API_RESPONSE', - API_ERROR = 'PLUGIN_API_ERROR', - - // Hook events - HOOK_EVENT = 'PLUGIN_HOOK_EVENT', - - // Dialog interaction - DIALOG_BUTTON_CLICK = 'PLUGIN_DIALOG_BUTTON_CLICK', - DIALOG_BUTTON_RESPONSE = 'PLUGIN_DIALOG_BUTTON_RESPONSE', - - // Message forwarding - MESSAGE = 'PLUGIN_MESSAGE', - MESSAGE_RESPONSE = 'PLUGIN_MESSAGE_RESPONSE', - MESSAGE_ERROR = 'PLUGIN_MESSAGE_ERROR', - - // Plugin lifecycle - READY = 'plugin-ready', -} - export interface PluginIframeConfig { pluginId: string; manifest: PluginManifest; @@ -340,8 +316,11 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => { `; }; +// Store blob URLs for cleanup +const activeBlobUrls = new Set(); + /** - * Create iframe URL with injected API + * Create iframe URL with injected API using blob URLs instead of data URLs */ export const createPluginIframeUrl = (config: PluginIframeConfig): string => { const apiScript = createPluginApiScript(config); @@ -367,8 +346,34 @@ export const createPluginIframeUrl = (config: PluginIframeConfig): string => { html = html + apiScript; } - // Convert to data URL - return 'data:text/html;base64,' + btoa(unescape(encodeURIComponent(html))); + // Create blob URL instead of data URL + const blob = new Blob([html], { type: 'text/html' }); + const blobUrl = URL.createObjectURL(blob); + + // Track the blob URL for cleanup + activeBlobUrls.add(blobUrl); + + return blobUrl; +}; + +/** + * Clean up a blob URL when iframe is no longer needed + */ +export const cleanupPluginIframeUrl = (url: string): void => { + if (url.startsWith('blob:') && activeBlobUrls.has(url)) { + URL.revokeObjectURL(url); + activeBlobUrls.delete(url); + } +}; + +/** + * Clean up all active blob URLs (useful for cleanup on destroy) + */ +export const cleanupAllPluginIframeUrls = (): void => { + activeBlobUrls.forEach((url) => { + URL.revokeObjectURL(url); + }); + activeBlobUrls.clear(); }; /** diff --git a/src/app/root-store/meta/task-shared.actions.ts b/src/app/root-store/meta/task-shared.actions.ts index 41a93d590a..310d1910e0 100644 --- a/src/app/root-store/meta/task-shared.actions.ts +++ b/src/app/root-store/meta/task-shared.actions.ts @@ -4,7 +4,7 @@ import { Task, TaskWithSubTasks } from '../../features/tasks/task.model'; import { IssueDataReduced } from '../../features/issue/issue.model'; import { WorkContextType } from '../../features/work-context/work-context.model'; import { Project } from '../../features/project/project.model'; -import { BatchOperation } from '../../api/batch-update-types'; +import { BatchOperation } from '@super-productivity/plugin-api'; /** * Shared actions that affect multiple reducers (tasks, projects, tags) diff --git a/src/index.html b/src/index.html index f7b8aaa1fa..5beb83b250 100644 --- a/src/index.html +++ b/src/index.html @@ -77,7 +77,7 @@ TODO configure more restrictive Content-Security-Policy @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ -->