feat(plugins): update plugin infrastructure and cleanup

This commit is contained in:
Johannes Millan 2025-07-10 14:58:44 +02:00
parent 37244c7d2f
commit 24fced4617
17 changed files with 227 additions and 340 deletions

View file

@ -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"
],

View file

@ -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';

View file

@ -4,7 +4,6 @@
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,

View file

@ -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;
}

View file

@ -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<BatchUpdateResult>;
}
}
// Re-export types for convenience
export * from '../api/batch-update-types';

View file

@ -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<string, Map<Hooks, Array<PluginHookHandler>>>();
private _hookHandlers = new Map<string, Map<Hooks, Array<PluginHookHandler<any>>>>();
private _headerButtons: Array<PluginHeaderBtnCfg> = [];
private _menuEntries: Array<PluginMenuEntryCfg> = [];
private _shortcuts: Array<PluginShortcutCfg> = [];
@ -67,7 +68,7 @@ export class PluginAPI implements PluginAPIInterface {
}
}
registerHook(hook: Hooks, fn: PluginHookHandler): void {
registerHook<T extends Hooks>(hook: T, fn: PluginHookHandler<T>): 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<any>);
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<any>);
}
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<unknown> {
async batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult> {
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 {

View file

@ -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<BatchUpdateResult> {
typia.assert<BatchUpdateRequest>(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<T extends Hooks>(
pluginId: string,
hook: T,
handler: PluginHookHandler<T>,
): void {
typia.assert<string>(pluginId);
typia.assert<Hooks>(hook);
typia.assert<PluginHookHandler>(handler);
// Note: Can't assert generic function type with typia
this._pluginHooksService.registerHookHandler(pluginId, hook, handler);
}

View file

@ -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();
}
}

View file

@ -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]) => {

View file

@ -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<Hooks, Map<string, PluginHookHandler>>();
private _handlers = new Map<Hooks, Map<string, PluginHookHandler<any>>>();
/**
* Register a hook handler
*/
registerHookHandler(pluginId: string, hook: Hooks, handler: PluginHookHandler): void {
registerHookHandler<T extends Hooks>(
pluginId: string,
hook: T,
handler: PluginHookHandler<T>,
): void {
if (!this._handlers.has(hook)) {
this._handlers.set(hook, new Map());
}

View file

@ -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<PluginInstance | null> {
async activatePlugin(
pluginId: string,
isManualActivation: boolean = false,
): Promise<PluginInstance | null> {
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<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 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;
}

View file

@ -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<void> {
// 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);

View file

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

View file

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

View file

@ -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<string>();
/**
* 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();
};
/**

View file

@ -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)

View file

@ -77,7 +77,7 @@ TODO configure more restrictive Content-Security-Policy
@see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ -->
<meta
content="default-src * uploaded:;
frame-src * data:;
frame-src * data: blob:;
font-src * data:;
img-src * data: filesystem: file:;
style-src * 'unsafe-inline' ;