From eb381c187f891473a3f92dc5a1b592a64db6cd69 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 18 May 2026 12:37:09 +0200 Subject: [PATCH] fix(plugins): allow iframe-only plugin installs --- docs/plugin-development.md | 13 +- docs/wiki/2.15-Develop-a-Plugin.md | 6 +- docs/wiki/3.01-API.md | 3 + packages/plugin-dev/README.md | 5 +- .../plugins/plugin-loader.iframe-only.spec.ts | 153 ++++++++++++++++ src/app/plugins/plugin-loader.service.ts | 48 ++++- .../plugin.service.load-from-zip.spec.ts | 169 ++++++++++++++++++ src/app/plugins/plugin.service.ts | 40 +++-- 8 files changed, 405 insertions(+), 32 deletions(-) create mode 100644 src/app/plugins/plugin-loader.iframe-only.spec.ts create mode 100644 src/app/plugins/plugin.service.load-from-zip.spec.ts diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 370da0a6d8..fe7138c2d9 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -33,11 +33,16 @@ If you want to build a sophisticated UI there is a boilerplate available for sol ``` my-plugin/ ├── manifest.json # Plugin metadata (required) -├── plugin.js # Main plugin code that is launched when activated and when Super Productivity starts -├── index.html # UI interface (optional) => requires iFrame:true in manifest +├── plugin.js # Host-side plugin code (optional for iframe-only plugins) +├── index.html # UI interface (required when omitting plugin.js; requires iFrame:true in manifest) └── icon.svg # Plugin icon (optional) ``` +`plugin.js` is required for plugins that need host-side setup at plugin load time, +shortcuts, header buttons, background behavior, or host-side API handlers. A UI-only +iframe plugin can ship only `manifest.json` and `index.html` when the manifest sets +`iFrame: true`. + ### 2. Minimal Example **manifest.json:** @@ -162,6 +167,10 @@ Plugins that render custom UI in a sandboxed iframe. - You need custom UI/visualizations - You want to display charts, forms, or complex interfaces +Iframe-only plugins do not need a `plugin.js` file if all plugin behavior lives inside +`index.html`. Super Productivity automatically adds the default menu or side-panel entry +from the manifest when the plugin is loaded. + **Important:** When using iframes, you must inline all CSS and JavaScript directly in the HTML file. External stylesheets and scripts are blocked for security reasons. **Example index.html:** diff --git a/docs/wiki/2.15-Develop-a-Plugin.md b/docs/wiki/2.15-Develop-a-Plugin.md index 7a51f225e6..ec87bae1dc 100644 --- a/docs/wiki/2.15-Develop-a-Plugin.md +++ b/docs/wiki/2.15-Develop-a-Plugin.md @@ -8,8 +8,10 @@ Plugins extend the app with custom UI, hooks (e.g. on task complete), and API ac 1. Create a folder with at least: - `manifest.json` (id, name, version, description, manifestVersion, minSupVersion) - - `plugin.js` (runs on load; can register header buttons, shortcuts, hooks) -2. Optionally add `index.html` (and set `iFrame: true` in the manifest) for custom UI. + - `plugin.js` (runs on load; can register header buttons, shortcuts, hooks), or + `index.html` for an iframe-only plugin +2. Add `index.html` and set `iFrame: true` in the manifest for custom iframe UI. If all behavior lives in + `index.html`, `plugin.js` can be omitted. 3. In the app: **Settings** → **Plugins** → **Load Plugin from Folder** and select your plugin directory. 4. Use DevTools (F12 or Ctrl+Shift+I) to debug. diff --git a/docs/wiki/3.01-API.md b/docs/wiki/3.01-API.md index 328233b50f..89e5e1691d 100755 --- a/docs/wiki/3.01-API.md +++ b/docs/wiki/3.01-API.md @@ -178,6 +178,9 @@ Core types (Task, Project, Tag, ProjectFolder, etc.) and batch types (`BatchUpda Plugins require `manifest.json`: `name`, `id`, `manifestVersion`, `version`, `minSupVersion`, optional `description`, `hooks`, `permissions`, optional `iFrame`, `isSkipMenuEntry`, `type`, `assets`, `icon`, `nodeScriptConfig`, `sidePanel`, `jsonSchemaCfg`. See `PluginManifest` in the repo. +Plugin packages normally include `plugin.js` for host-side behavior. Iframe-only plugins may omit `plugin.js` when +`iFrame: true` is set and `index.html` is included. + ### Plugin Permissions - **nodeExecution:** Required for `executeNodeScript()` (Electron only; user consent). See [[3.05-Web-App-vs-Desktop]] and [[3.06-User-Data]] for file-system and desktop-only behavior. diff --git a/packages/plugin-dev/README.md b/packages/plugin-dev/README.md index 7517966916..887bfc7803 100644 --- a/packages/plugin-dev/README.md +++ b/packages/plugin-dev/README.md @@ -72,7 +72,7 @@ my-plugin/ ├── scripts/ │ └── package.js # Script to create plugin.zip └── dist/ # Build output - ├── plugin.js # Compiled plugin code + ├── plugin.js # Compiled plugin code (optional for iframe-only plugins) ├── manifest.json # Copied manifest └── plugin.zip # Packaged plugin ``` @@ -225,7 +225,8 @@ This creates `dist/plugin.zip` ready for distribution. Your plugin ZIP must contain: - `manifest.json` - Plugin metadata -- `plugin.js` - Main plugin code +- `plugin.js` - Main plugin code, unless this is an iframe-only plugin with `iFrame: true` + and `index.html` Optional files: diff --git a/src/app/plugins/plugin-loader.iframe-only.spec.ts b/src/app/plugins/plugin-loader.iframe-only.spec.ts new file mode 100644 index 0000000000..6f60c3eba3 --- /dev/null +++ b/src/app/plugins/plugin-loader.iframe-only.spec.ts @@ -0,0 +1,153 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Observable, of, throwError } from 'rxjs'; +import { PluginCacheService } from './plugin-cache.service'; +import { PluginLoaderService } from './plugin-loader.service'; +import { PluginManifest } from './plugin-api.model'; + +describe('PluginLoaderService iframe-only plugins', () => { + let service: PluginLoaderService; + let httpGet: jasmine.Spy; + + const pluginPath = '/plugins/iframe-only'; + const iframeManifest: PluginManifest = { + id: 'iframe-only', + name: 'Iframe Only', + manifestVersion: 1, + version: '1.0.0', + minSupVersion: '18.0.0', + hooks: [], + permissions: [], + iFrame: true, + }; + + const expectRejectsWithHttpStatus = async ( + promise: Promise, + status: number, + ): Promise => { + try { + await promise; + fail(`Expected promise to reject with HTTP status ${status}`); + } catch (error) { + expect(error).toEqual(jasmine.any(HttpErrorResponse)); + expect((error as HttpErrorResponse).status).toBe(status); + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + PluginLoaderService, + { provide: HttpClient, useValue: { get: jasmine.createSpy('get') } }, + { + provide: PluginCacheService, + useValue: jasmine.createSpyObj('PluginCacheService', [ + 'getPlugin', + 'clearCache', + ]), + }, + ], + }); + + service = TestBed.inject(PluginLoaderService); + httpGet = TestBed.inject(HttpClient).get as jasmine.Spy; + }); + + it('loads an iframe plugin without plugin.js when index.html is available', async () => { + const indexHtml = 'Plugin UI'; + httpGet.and.callFake((url: string): Observable => { + if (url === `${pluginPath}/manifest.json`) { + return of(JSON.stringify(iframeManifest)); + } + if (url === `${pluginPath}/plugin.js`) { + return throwError( + () => new HttpErrorResponse({ status: 404, statusText: 'Not Found' }), + ); + } + if (url === `${pluginPath}/index.html`) { + return of(indexHtml); + } + return throwError(() => new Error(`Unexpected URL: ${url}`)); + }); + + const result = await service.loadPluginAssets(pluginPath); + + expect(result.manifest).toEqual(iframeManifest); + expect(result.code).toBe(''); + expect(result.indexHtml).toBe(indexHtml); + }); + + it('still requires plugin.js for plugins without iframe UI', async () => { + const standardManifest: PluginManifest = { + ...iframeManifest, + iFrame: false, + }; + httpGet.and.callFake((url: string): Observable => { + if (url === `${pluginPath}/manifest.json`) { + return of(JSON.stringify(standardManifest)); + } + if (url === `${pluginPath}/plugin.js`) { + return throwError(() => new HttpErrorResponse({ status: 404 })); + } + return throwError(() => new Error(`Unexpected URL: ${url}`)); + }); + + await expectRejectsWithHttpStatus(service.loadPluginAssets(pluginPath), 404); + }); + + it('requires index.html when plugin.js is missing for an iframe plugin', async () => { + httpGet.and.callFake((url: string): Observable => { + if (url === `${pluginPath}/manifest.json`) { + return of(JSON.stringify(iframeManifest)); + } + if (url === `${pluginPath}/plugin.js`) { + return throwError(() => new HttpErrorResponse({ status: 404 })); + } + if (url === `${pluginPath}/index.html`) { + return throwError(() => new Error('404 index.html')); + } + return throwError(() => new Error(`Unexpected URL: ${url}`)); + }); + + await expectRejectsWithHttpStatus(service.loadPluginAssets(pluginPath), 404); + }); + + it('does not hide non-404 plugin.js load failures', async () => { + httpGet.and.callFake((url: string): Observable => { + if (url === `${pluginPath}/manifest.json`) { + return of(JSON.stringify(iframeManifest)); + } + if (url === `${pluginPath}/plugin.js`) { + return throwError( + () => + new HttpErrorResponse({ + status: 500, + statusText: 'Internal Server Error', + }), + ); + } + return throwError(() => new Error(`Unexpected URL: ${url}`)); + }); + + await expectRejectsWithHttpStatus(service.loadPluginAssets(pluginPath), 500); + }); + + it('requires non-empty index.html when plugin.js is missing', async () => { + httpGet.and.callFake((url: string): Observable => { + if (url === `${pluginPath}/manifest.json`) { + return of(JSON.stringify(iframeManifest)); + } + if (url === `${pluginPath}/plugin.js`) { + return throwError(() => new HttpErrorResponse({ status: 404 })); + } + if (url === `${pluginPath}/index.html`) { + return of(' '); + } + return throwError(() => new Error(`Unexpected URL: ${url}`)); + }); + + await expectAsync(service.loadPluginAssets(pluginPath)).toBeRejectedWithError( + 'Plugin iframe-only requires a non-empty index.html when plugin.js is missing', + ); + }); +}); diff --git a/src/app/plugins/plugin-loader.service.ts b/src/app/plugins/plugin-loader.service.ts index 74b3d0f9d8..1d560d5909 100644 --- a/src/app/plugins/plugin-loader.service.ts +++ b/src/app/plugins/plugin-loader.service.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { first } from 'rxjs/operators'; import { PluginManifest } from './plugin-api.model'; @@ -60,16 +60,29 @@ export class PluginLoaderService { }); } - // Load plugin code - const codeUrl = `${pluginPath}/plugin.js`; - const code = await this._http - .get(codeUrl, { responseType: 'text' }) - .pipe(first()) - .toPromise(); - // Load optional assets let indexHtml: string | undefined; let icon: string | undefined; + let code = ''; + let hasPluginJs = false; + let pluginJsLoadError: unknown; + + // Load plugin code. Iframe-only plugins can omit plugin.js when index.html is + // available, so defer throwing until optional iframe assets have been checked. + const codeUrl = `${pluginPath}/plugin.js`; + try { + code = + (await this._http + .get(codeUrl, { responseType: 'text' }) + .pipe(first()) + .toPromise()) ?? ''; + hasPluginJs = true; + } catch (error) { + if (!manifest.iFrame || !this._isOptionalAssetNotFound(error)) { + throw error; + } + pluginJsLoadError = error; + } if (manifest.iFrame) { try { @@ -80,9 +93,18 @@ export class PluginLoaderService { .toPromise(); } catch (e) { PluginLog.err(`No index.html for plugin ${manifest.id}`); + if (!hasPluginJs) { + throw pluginJsLoadError ?? e; + } } } + if (!hasPluginJs && !this._hasPluginIndexHtml(indexHtml)) { + throw new Error( + `Plugin ${manifest.id} requires a non-empty index.html when plugin.js is missing`, + ); + } + if (manifest.icon) { try { const iconUrl = `${pluginPath}/${manifest.icon}`; @@ -158,4 +180,14 @@ export class PluginLoaderService { async clearAllCaches(): Promise { await this._cacheService.clearCache(); } + + private _isOptionalAssetNotFound(error: unknown): boolean { + return ( + error instanceof HttpErrorResponse && (error.status === 0 || error.status === 404) + ); + } + + private _hasPluginIndexHtml(indexHtml: string | undefined): boolean { + return indexHtml !== undefined && indexHtml.trim().length > 0; + } } diff --git a/src/app/plugins/plugin.service.load-from-zip.spec.ts b/src/app/plugins/plugin.service.load-from-zip.spec.ts new file mode 100644 index 0000000000..a68e984fe8 --- /dev/null +++ b/src/app/plugins/plugin.service.load-from-zip.spec.ts @@ -0,0 +1,169 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { strToU8, zipSync } from 'fflate'; +import { of } from 'rxjs'; +import { GlobalThemeService } from '../core/theme/global-theme.service'; +import { SnackService } from '../core/snack/snack.service'; +import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service'; +import { T } from '../t.const'; +import { PluginCacheService } from './plugin-cache.service'; +import { PluginCleanupService } from './plugin-cleanup.service'; +import { PluginHooksService } from './plugin-hooks'; +import { PluginI18nService } from './plugin-i18n.service'; +import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service'; +import { PluginLoaderService } from './plugin-loader.service'; +import { PluginManifest } from './plugin-api.model'; +import { PluginMetaPersistenceService } from './plugin-meta-persistence.service'; +import { PluginRunner } from './plugin-runner'; +import { PluginSecurityService } from './plugin-security'; +import { PluginService } from './plugin.service'; +import { PluginUserPersistenceService } from './plugin-user-persistence.service'; + +describe('PluginService loadPluginFromZip iframe-only plugins', () => { + let service: PluginService; + let pluginRunner: jasmine.SpyObj; + let pluginCache: jasmine.SpyObj; + + const iframeManifest: PluginManifest = { + id: 'iframe-only', + name: 'Iframe Only', + manifestVersion: 1, + version: '1.0.0', + minSupVersion: '18.0.0', + hooks: [], + permissions: [], + iFrame: true, + }; + + const createZipFile = (files: Record): File => { + const entries: Record = {}; + for (const [path, content] of Object.entries(files)) { + entries[path] = strToU8(content); + } + const zipBytes = zipSync(entries); + const zipBuffer = new ArrayBuffer(zipBytes.byteLength); + new Uint8Array(zipBuffer).set(zipBytes); + return new File([zipBuffer], 'plugin.zip', { type: 'application/zip' }); + }; + + beforeEach(() => { + pluginRunner = jasmine.createSpyObj('PluginRunner', [ + 'loadPlugin', + 'triggerReady', + 'unloadPlugin', + 'pingNodeBridge', + ]); + pluginRunner.loadPlugin.and.callFake( + async (manifest, _pluginCode, _baseCfg, isEnabled = true) => ({ + manifest, + loaded: true, + isEnabled, + }), + ); + pluginRunner.triggerReady.and.resolveTo(); + + const pluginSecurity = jasmine.createSpyObj( + 'PluginSecurityService', + ['analyzePluginCode', 'hasElevatedPermissions'], + ); + pluginSecurity.analyzePluginCode.and.returnValue({ warnings: [], info: [] }); + pluginSecurity.hasElevatedPermissions.and.returnValue(false); + + pluginCache = jasmine.createSpyObj('PluginCacheService', [ + 'storePlugin', + 'getPlugin', + 'removePlugin', + ]); + pluginCache.storePlugin.and.resolveTo(); + + const pluginMetaPersistence = jasmine.createSpyObj( + 'PluginMetaPersistenceService', + ['isPluginEnabled', 'setPluginEnabled'], + ); + pluginMetaPersistence.isPluginEnabled.and.resolveTo(true); + + const translateService = jasmine.createSpyObj('TranslateService', [ + 'instant', + ]); + translateService.instant.and.callFake((key: string | string[]) => + Array.isArray(key) ? key.join(',') : key, + ); + + TestBed.configureTestingModule({ + providers: [ + PluginService, + { provide: HttpClient, useValue: { get: () => of(null) } }, + { provide: PluginRunner, useValue: pluginRunner }, + { provide: PluginHooksService, useValue: {} }, + { provide: PluginSecurityService, useValue: pluginSecurity }, + { provide: GlobalThemeService, useValue: { darkMode: () => 'light' } }, + { provide: PluginMetaPersistenceService, useValue: pluginMetaPersistence }, + { provide: PluginUserPersistenceService, useValue: {} }, + { provide: PluginCacheService, useValue: pluginCache }, + { provide: MatDialog, useValue: {} }, + { provide: PluginCleanupService, useValue: {} }, + { provide: PluginLoaderService, useValue: {} }, + { provide: TranslateService, useValue: translateService }, + { provide: PluginI18nService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: PluginIssueProviderRegistryService, useValue: {} }, + { provide: IssueSyncAdapterRegistryService, useValue: {} }, + { provide: SnackService, useValue: {} }, + ], + }); + + service = TestBed.inject(PluginService); + }); + + it('loads an iframe plugin zip without plugin.js when index.html exists', async () => { + const indexHtml = 'Plugin UI'; + const files: Record = {}; + files['manifest.json'] = JSON.stringify(iframeManifest); + files['index.html'] = indexHtml; + const file = createZipFile(files); + + const result = await service.loadPluginFromZip(file); + + expect(result.loaded).toBeTrue(); + expect(pluginRunner.loadPlugin).toHaveBeenCalledWith( + iframeManifest, + '', + jasmine.objectContaining({ theme: 'light', platform: 'web' }), + true, + ); + expect(pluginCache.storePlugin).toHaveBeenCalledWith( + iframeManifest.id, + JSON.stringify(iframeManifest), + '', + indexHtml, + undefined, + undefined, + undefined, + ); + expect(service.getPluginIndexHtml(iframeManifest.id)).toBe(indexHtml); + }); + + it('rejects a plugin zip without plugin.js when index.html is absent', async () => { + const files: Record = {}; + files['manifest.json'] = JSON.stringify(iframeManifest); + const file = createZipFile(files); + + await expectAsync(service.loadPluginFromZip(file)).toBeRejectedWithError( + T.PLUGINS.PLUGIN_JS_NOT_FOUND, + ); + }); + + it('rejects a plugin zip without plugin.js when index.html is empty', async () => { + const files: Record = {}; + files['manifest.json'] = JSON.stringify(iframeManifest); + files['index.html'] = ' '; + const file = createZipFile(files); + + await expectAsync(service.loadPluginFromZip(file)).toBeRejectedWithError( + T.PLUGINS.INDEX_HTML_NOT_LOADED, + ); + }); +}); diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index 234b7d5d71..848fcb4e5a 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -1126,26 +1126,11 @@ export class PluginService implements OnDestroy { ); } - // Find and extract plugin.js - if (!extractedFiles['plugin.js']) { - throw new Error(this._translateService.instant(T.PLUGINS.PLUGIN_JS_NOT_FOUND)); - } - - // Validate plugin.js size - const pluginCodeBytes = extractedFiles['plugin.js']; - 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), - }), - ); - } - - const pluginCode = new TextDecoder().decode(pluginCodeBytes); - // Extract index.html if it exists (optional) and iFrame is true let indexHtml: string | null = null; - if (manifest.iFrame && extractedFiles['index.html']) { + 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) { @@ -1158,6 +1143,25 @@ export class PluginService implements OnDestroy { 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]) {