Plugin System Overview
This document explains the design philosophy, architecture, and key concepts of the OpenGPEX v2 plugin system. The plugin contract is largely unchanged from v1; what changed in v2 is the engine the plugins live in — a WebGPU-based render pipeline with a sharded asset store. Start here before writing any plugin code.
Design Philosophy
OpenGPEX strictly follows Inversion of Control (IoC) and the Open-Closed Principle to achieve complete decoupling between the core engine framework (Workspace) and business plugins.
┌─────────────────────────────────────────────────────────┐
│ Core Engine (Workspace) │
│ │
│ Registry ──── auto-discovers ────► Plugin A │
│ ────► Plugin B │
│ ────► Plugin C │
│ │
│ Workspace ── renders via Slots ── Registry │
└─────────────────────────────────────────────────────────┘
The core never imports plugins directly. Plugins register themselves into a metadata-driven registry, and the workspace dynamically renders them into physical slots.
Dual-Track Loading Architecture
OpenGPEX uses a dual-track plugin loading system:
| Track | Method | Use Case |
|---|---|---|
| Track 1: Static | Source compiled into the application bundle | Official built-in plugins, local development |
| Track 2: Dynamic | ESM ZIP loaded at runtime via import() |
Production hot-plug install, user uploads |
┌─────────────────────────────┬────────────────────────────────┐
│ Track 1: Static Compile │ Track 2: Dynamic Runtime │
│ │ │
│ plugins/base/ → bundle │ data/plugins/user/ → ESM load │
│ plugins/community/ → bundle│ /api/plugins/serve (proxy) │
│ plugins/user/ → bundle │ /api/plugins/upload (ingest) │
│ │ │
│ registry.ts (generated) │ /api/plugins/list (discovery) │
└─────────────────────────────┴────────────────────────────────┘
Dual-Registry System
The system maintains two separate registries:
core/plugin/registry.ts— All officialbaseandcommunityplugins, generated bysync-plugins.mjsplugins/registry-user.ts— All localuserplugins, generated byscan-plugins.mjs
The core loader usePluginInit loads both registries in sequence ("core first, user second") and hands them to PluginService for unified lifecycle management.
Trust Model (sourceType)
Every plugin carries a trust level:
| sourceType | Meaning | Install Method | Removable | Disablable |
|---|---|---|---|---|
base |
Official core features | Compiled into bundle | No | No |
community |
Verified third-party, pre-installed | Compiled into bundle | No | Yes |
user |
User-installed | ZIP upload / Hub download | Yes | Yes |
Users can never elevate a plugin to
communitystatus at runtime. Only plugins shipped with the release qualify.communityis a compile-time concept.
UID Namespace System
Every plugin, command, and signal receives a globally unique identifier (UID):
| Entity | Formula | Example |
|---|---|---|
| Plugin | author + "." + manifest.id |
opengpex.drawers.adjustments |
| Command | plugin.uid + "." + cmd.id |
opengpex.drawers.image_info.cmd.download |
| Signal | plugin.uid + "." + sig.id |
opengpex.drawers.craft_tools.signal.active_craft |
This ensures zero naming collisions even when multiple plugins declare commands with the same local name.
Scoped Auto-Prefix
When a plugin calls actions.executeCommand(id) from within its own context:
- If
idis found directly in the global command registry → execute as-is (absolute path) - Otherwise → auto-prefix with
${pluginScope.uid}.${id}(relative path)
Plugins can therefore use short IDs internally (e.g., 'cmd.apply_color') without namespace collisions. The same auto-prefixing applies to signals, plugin config keys, and busy state.
Plugin Metadata Contract
Every plugin exports a plugin object implementing the EditorPlugin interface:
import * as P from './protocols';
export const plugin: EditorPlugin = {
// 1. Identity
manifest: {
id: P.PLUGIN_ID, // e.g. 'drawers.my_tool'
displayName: 'My Tool',
version: '1.0.0',
description: 'A custom tool',
category: 'drawers',
author: P.PLUGIN_AUTHOR, // REQUIRED — participates in UID computation
requirements: {
coreVersion: '>=2.0.0', // minimum engine version
auth: 'none',
},
},
// 2. Layout
slot: 'SIDE_BAR',
show: 'frame-required',
icon: <Palette size={20} />,
order: 900,
side: 'left',
// 3. Functionality
component: MyPanel,
initialConfig: P.DEFAULT_CONFIG,
commands: [...],
signals: [...],
interactions: [...],
contributions: [
{ slot: 'OPTION_BAR', component: MyOptionsBar },
],
// 4. Drawer Auto-Reveal (SIDE_BAR plugins only)
autoReveal: {
when: (state) => !!state.activeFrameId,
priority: 100,
},
// 5. Lifecycle
onInit: (ctx) => { /* ctx.actions only — see lifecycle rules */ },
onDestroy: (ctx) => { /* must be idempotent */ },
};
At runtime the framework assembles a BuiltPlugin — the same shape plus computed uid, group, and enabled fields:
export interface BuiltPlugin extends Omit<EditorPlugin, 'commands' | 'signals'> {
uid: string; // e.g. "opengpex.options.clip_options"
group: string; // e.g. "gpex.plugins.base."
enabled: boolean; // controlled by the user in the Hub panel
commands?: BuiltCommand[];
signals?: BuiltSignal[];
}
Enable / Disable Semantics
All detected plugins are loaded and registered at editor startup. Mounting is controlled solely by the runtime enabled flag:
- Enabled — commands are mounted, shortcuts bound, UI rendered into its slot.
- Disabled — command/signal registration is intercepted, shortcuts are unbound, and the plugin is invisible to all UI slots.
The v2 Engine Context
Plugins do not touch the render pipeline directly, but v2 changes the environment they operate in:
- WebGPU renderer. The scene is assembled and composited on the GPU. Plugins that manipulate layers see the same logical layer model; results render through the new pipeline. Note that WebGPU rejects zero-sized textures outright — when generating bitmaps yourself (see Plugin Workers), always produce non-zero dimensions.
- Sharded asset store. The
AssetServiceAPI was reworked around content-hashed asset records (assets.storeBundle,assets.getURL,assets.getDec,assets.sweep). See the API Reference. - Core image Worker. All heavy pixel work (composite, resample, rasterize, histogram) runs in a single shared core Worker with a typed job protocol. Plugins must never piggyback on it — they own their own Workers (see Plugin Workers).
- Presets-based preferences. Cross-cutting editor preferences (e.g. Smart Guides snap settings) moved to a central
PresetsFactoryread viapresets.get()/usePreset(). Plugin-private settings still useusePluginSelfConfig.
Everything else — UID namespacing, the command bus, signals, slots, the trust model, packaging — is unchanged from v1.
Service Layering
PluginService is a plain class instance in the non-React core layer. React binds to it through usePluginList(), which subscribes via useSyncExternalStore and returns a fresh snapshot array on every registry change:
Non-React core layer React binding layer Consumer
[PluginService] ──notify──► [usePluginList()] ──────► [useViewportEvents, Hub panel]
Never call plugins.getAllPlugins() from React components — it returns a non-reactive snapshot and causes subscription races. Always go through usePluginList().
Next Steps
- Your First Plugin — build a working drawer end to end
- API Reference — every hook and context service
- Slots & UI — where plugins render
- Security & Isolation — the three-tier safety net
Last updated: 2026-10-12