Launch OpenGPEX

OpenGPEX 1.x documentation (legacy editor).

v2 Docs

Plugin System Overview

This document explains the design philosophy, architecture, and key concepts of the OpenGPEX plugin system. Whether you want to build a custom tool or understand how the editor is structured, start here.


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. Instead, 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 Next.js 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 ❌ ❌
community Verified third-party, pre-installed Compiled into bundle ❌ ✅
user User-installed ZIP upload / Hub download ✅ ✅

📌 Users can never elevate a plugin to community status at runtime. Only plugins shipped with the release qualify.


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.adjustments.signal.active_grading_tool

The author segment is lowercased and sanitized (non [a-z0-9_-] characters become _) before the UID is computed.

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)

This means plugins can use short IDs internally (e.g., 'cmd.apply_color') without worrying about namespace collisions.


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 drawing tool',
    category: 'drawers',
    author: P.PLUGIN_AUTHOR,      // e.g. 'my-username' — REQUIRED for UID
    requirements: {
      coreVersion: '>=1.0.0',     // minimum engine version
      auth: 'none',
    },
  },

  // 2. Layout
  slot: 'SIDE_BAR',
  show: 'frame-required',
  icon: <Palette size={20} />,
  order: 400,

  // 3. Implementation
  component: MyToolComponent,
  initialConfig: { mode: 'normal' },

  // 4. Capabilities
  commands: [/* ... */],
  signals: [/* ... */],
  interactions: [/* ... */],
  contributions: [/* ... */],

  // 5. Lifecycle
  onInit: (ctx) => { /* setup */ },
  onDestroy: (ctx) => { /* cleanup */ },
};

⚠️ Both manifest.id and manifest.author are required. The system computes UID as author + "." + id. Missing either field will cause registration to fail.


Slot System

Plugins render into physical slots — predefined UI regions in the workspace:

Slot Location Plugin Suffix Convention
SIDE_BAR Sidebar drawers (left & right sides) XxxDrawer, XxxPanel
OPTION_BAR Top strip below the header XxxOptions
DOCK Collapsible bottom panel area TabDock
TL / TR / BL / BR Anchored HUD corners on the viewport XxxHUD, XxxWidget
VIEWPORT_OVERLAY Over the canvas (pointer-interactive) XxxOverlay
STAGE_OVERLAY Stage-level layers (non-interactive) XxxOverlay
STAGE_GIZMOS Interactive transform & crop tools SmartGuides
ROOT_OVERLAY App-level absolute layers XxxPopup, XxxOverlay
TOOL_MENU Left vertical toolbar options XxxTool
HIDDEN Headless background loops TimeTraveler, XxxService

Plugin Lifecycle

Install/Enable → onInit(ctx) → Active (renders in slot) → Disable → onDestroy(ctx)
  1. Registration — Plugin metadata is scanned and added to the registry
  2. Initialization — onInit fires with full EditorContextValue
  3. Active — Component renders in its assigned slot, commands/signals are live
  4. Destruction — onDestroy fires for cleanup when disabled or uninstalled

Lifecycle Rules

  • onInit / onDestroy should be synchronous and lightweight (fire-and-forget any async work)
  • onDestroy must be idempotent — multiple calls must not throw
  • No DOM manipulation or React re-renders in lifecycle hooks (they execute outside the React tree)
  • Extract lifecycle logic into a separate lifecycle.ts file

The 5-File Pattern

Every plugin follows a standard file structure:

plugins/base/[category]/[PluginName]/
├── index.tsx       # Plugin entry — exports EditorPlugin object
├── protocols.ts    # Constants (PLUGIN_ID, PLUGIN_AUTHOR, command IDs, signal IDs, config interfaces)
├── commands.ts     # Command executors (business logic)
├── hooks.ts        # Custom hooks (bridge UI ↔ commands/signals)
└── components.tsx  # React UI components (pure presentation)

Optional additional files:

  • lifecycle.ts — onInit / onDestroy implementations (when needed)
  • worker/ — Plugin-private Web Worker directory (for heavy computation)

Next Steps


Last updated: 2026-07-30