mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(plugins): allow iframe-only plugin installs
This commit is contained in:
parent
89ba858ce0
commit
eb381c187f
8 changed files with 405 additions and 32 deletions
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
153
src/app/plugins/plugin-loader.iframe-only.spec.ts
Normal file
153
src/app/plugins/plugin-loader.iframe-only.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
src/app/plugins/plugin.service.load-from-zip.spec.ts
Normal file
169
src/app/plugins/plugin.service.load-from-zip.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue