Launch OpenGPEX

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 official base and community plugins, generated by sync-plugins.mjs
  • plugins/registry-user.ts — All local user plugins, generated by scan-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 community status at runtime. Only plugins shipped with the release qualify. community is 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:

  1. If id is found directly in the global command registry → execute as-is (absolute path)
  2. 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 AssetService API 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 PresetsFactory read via presets.get() / usePreset(). Plugin-private settings still use usePluginSelfConfig.

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


Last updated: 2026-10-12