fix(plugins): allow iframe-only plugin installs

This commit is contained in:
Johannes Millan 2026-05-18 12:37:09 +02:00
parent 89ba858ce0
commit eb381c187f
8 changed files with 405 additions and 32 deletions

View file

@ -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:**

View file

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

View file

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

View file

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

View file

@ -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<unknown>,
status: number,
): Promise<void> => {
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>('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 = '<!doctype html><html><body>Plugin UI</body></html>';
httpGet.and.callFake((url: string): Observable<string> => {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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',
);
});
});

View file

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

View file

@ -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<PluginRunner>;
let pluginCache: jasmine.SpyObj<PluginCacheService>;
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<string, string>): File => {
const entries: Record<string, Uint8Array> = {};
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>('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>(
'PluginSecurityService',
['analyzePluginCode', 'hasElevatedPermissions'],
);
pluginSecurity.analyzePluginCode.and.returnValue({ warnings: [], info: [] });
pluginSecurity.hasElevatedPermissions.and.returnValue(false);
pluginCache = jasmine.createSpyObj<PluginCacheService>('PluginCacheService', [
'storePlugin',
'getPlugin',
'removePlugin',
]);
pluginCache.storePlugin.and.resolveTo();
const pluginMetaPersistence = jasmine.createSpyObj<PluginMetaPersistenceService>(
'PluginMetaPersistenceService',
['isPluginEnabled', 'setPluginEnabled'],
);
pluginMetaPersistence.isPluginEnabled.and.resolveTo(true);
const translateService = jasmine.createSpyObj<TranslateService>('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 = '<!doctype html><html><body>Plugin UI</body></html>';
const files: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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,
);
});
});

View file

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