diff --git a/lib/plugin-capabilities.js b/lib/plugin-capabilities.js new file mode 100644 index 0000000..e1839e4 --- /dev/null +++ b/lib/plugin-capabilities.js @@ -0,0 +1,27 @@ +export function createVirtualDisplayRegistry(defaultFactory) { + let provider = { name: 'core', factory: defaultFactory }; + + return { + register(pluginName, factory) { + if (typeof factory !== 'function') { + throw new TypeError(`virtual display provider for plugin "${pluginName}" must be a function`); + } + if (provider.name !== 'core') { + const error = new Error( + `virtual display provider conflict: "${provider.name}" already owns the capability; "${pluginName}" cannot also provide it` + ); + error.code = 'plugin_capability_conflict'; + throw error; + } + provider = { name: pluginName, factory }; + }, + + create() { + return provider.factory(); + }, + + get owner() { + return provider.name; + }, + }; +} diff --git a/lib/plugins.js b/lib/plugins.js index 6db2947..282eeb3 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -140,8 +140,8 @@ export function createPluginEvents() { * Load and register all plugins from plugins//index.js. * * @param {object} app - Express app - * @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession } - * Mutable -- plugins can replace ctx.createVirtualDisplay etc. + * @param {object} ctx - Shared plugin context. Each registration receives `ctx.plugin`, + * containing that plugin's name, settings, and scoped capabilities. * @returns {string[]} - Names of loaded plugins */ export async function loadPlugins(app, ctx, options = {}) { @@ -191,10 +191,19 @@ export async function loadPlugins(app, ctx, options = {}) { } const pluginConfig = pluginConfigs.get(name) || {}; - await register(app, ctx, pluginConfig); + const pluginCtx = Object.create(ctx); + pluginCtx.plugin = { + name, + settings: pluginConfig, + registerVirtualDisplayProvider(factory) { + return ctx.registerVirtualDisplayProvider(name, factory); + }, + }; + await register(app, pluginCtx, pluginConfig); loaded.push(name); ctx.log('info', 'plugin loaded', { plugin: name }); } catch (err) { + if (err?.code === 'plugin_capability_conflict') throw err; ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack }); } } diff --git a/plugins/vnc/AGENTS.md b/plugins/vnc/AGENTS.md index b5edfe9..29f8ac0 100644 --- a/plugins/vnc/AGENTS.md +++ b/plugins/vnc/AGENTS.md @@ -32,7 +32,7 @@ Disabled by default. Enable with `ENABLE_VNC=1` env var or `"vnc": { "enabled": ## Architecture -The plugin overrides `ctx.createVirtualDisplay` to use a higher-resolution display (default 1920x1080 instead of 1x1). `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top. +The plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top. ## Original Contributors diff --git a/plugins/vnc/README.md b/plugins/vnc/README.md index be688ae..fa57b1e 100644 --- a/plugins/vnc/README.md +++ b/plugins/vnc/README.md @@ -16,7 +16,7 @@ noVNC / websockify (port 6080) Your browser → http://localhost:6080/vnc.html ``` -The plugin overrides Camoufox's default 1x1 virtual display with a human-usable resolution, then runs a watcher process that detects the Xvfb display and attaches x11vnc + noVNC. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches. +The plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches. ## Quick start diff --git a/plugins/vnc/index.js b/plugins/vnc/index.js index 26080b7..7b15c52 100644 --- a/plugins/vnc/index.js +++ b/plugins/vnc/index.js @@ -50,9 +50,10 @@ import { removeXvfbDisplayFiles } from '../../lib/tmp-cleanup.js'; export async function register(app, ctx, pluginConfig = {}) { const { events, config, log, sessions, VirtualDisplay, safeError } = ctx; + const settings = ctx.plugin?.settings || pluginConfig; - // Resolve all config (env vars + pluginConfig) via the launcher module - const vncConfig = resolveVncConfig(pluginConfig); + // Resolve all config (env vars + plugin settings) via the launcher module + const vncConfig = resolveVncConfig(settings); if (!vncConfig.enabled) { log('info', 'vnc plugin: disabled (set ENABLE_VNC=1 or plugins.vnc.enabled=true)'); @@ -86,8 +87,8 @@ export async function register(app, ctx, pluginConfig = {}) { } } - ctx.createVirtualDisplay = () => new VncVirtualDisplay(); - log('info', 'vnc plugin: overriding Xvfb resolution', { resolution }); + ctx.plugin.registerVirtualDisplayProvider(() => new VncVirtualDisplay()); + log('info', 'vnc plugin: registered Xvfb display provider', { resolution }); // --- VNC watcher process --- log('info', 'vnc plugin enabled', { diff --git a/plugins/vnc/vnc.test.js b/plugins/vnc/vnc.test.js index 983bdbf..f3d3557 100644 --- a/plugins/vnc/vnc.test.js +++ b/plugins/vnc/vnc.test.js @@ -70,6 +70,7 @@ describe('vnc plugin', () => { mockApp = { get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }), }; + const displayProvider = { create: () => new MockVirtualDisplay() }; ctx = { events, config: {}, @@ -77,8 +78,11 @@ describe('vnc plugin', () => { sessions: new Map(), safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'), VirtualDisplay: MockVirtualDisplay, - createVirtualDisplay: () => new MockVirtualDisplay(), + plugin: { + registerVirtualDisplayProvider: (factory) => { displayProvider.create = factory; }, + }, }; + ctx.createVirtualDisplay = () => displayProvider.create(); mockStartWatcher.mockClear(); mockStartWatcher.mockImplementation(mockWatcher); mockResolveVncConfig.mockClear(); diff --git a/server.js b/server.js index 7298db4..c470365 100644 --- a/server.js +++ b/server.js @@ -48,6 +48,7 @@ import { createReporter, createTabHealthTracker, collectResourceSnapshot, classi import { mountDocs } from './lib/openapi.js'; import { initSentry, captureException as sentryCaptureException, setupExpressErrorHandler as setupSentryErrorHandler, flush as sentryFlush } from './lib/sentry.js'; import { prepareExternalCamoufoxExecutable } from './lib/camoufox-executable.js'; +import { createVirtualDisplayRegistry } from './lib/plugin-capabilities.js'; import { killProcessIds } from './lib/browser-processes.js'; import { snapshotOwnedBrowserProcesses, survivingOwnedBrowserProcesses, profilePathsFromProcessSnapshot } from './lib/process-ownership.js'; import { @@ -1082,6 +1083,8 @@ function isCamoufoxGeoipError(err) { return /Invalid locale:|GeoLite|MaxMind|geolocation|public proxy IP address|GeoIP setup timed out/i.test(err?.message || String(err || '')); } +const virtualDisplayRegistry = createVirtualDisplayRegistry(() => new DefaultVirtualDisplay()); + async function buildLaunchOptionsWithGeoipFallback(baseOptions, attemptMeta) { try { return await withTimeout(launchOptions(baseOptions), GEOIP_SETUP_TIMEOUT_MS, 'GeoIP setup'); @@ -1114,7 +1117,7 @@ async function launchBrowserInstance() { let candidateBrowser = null; try { if (os.platform() === 'linux' && !useDesktopWindow) { - localVirtualDisplay = pluginCtx.createVirtualDisplay(); + localVirtualDisplay = virtualDisplayRegistry.create(); vdDisplay = await localVirtualDisplay.get(); log('info', 'xvfb virtual display started', { display: vdDisplay, attempt }); } @@ -7012,8 +7015,7 @@ const pluginCtx = { failuresTotal, metricsRegistry: getRegister, createMetric, - /** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */ - createVirtualDisplay: () => new DefaultVirtualDisplay(), + registerVirtualDisplayProvider: (pluginName, factory) => virtualDisplayRegistry.register(pluginName, factory), /** The upstream VirtualDisplay class -- plugins can subclass it. */ VirtualDisplay, }; diff --git a/tests/unit/launchCompat.test.js b/tests/unit/launchCompat.test.js index 84ae23b..7818307 100644 --- a/tests/unit/launchCompat.test.js +++ b/tests/unit/launchCompat.test.js @@ -31,7 +31,7 @@ describe('launch compatibility source contract', () => { expect(defaultVirtualDisplay).toContain("DEFAULT_VIRTUAL_DISPLAY_RESOLUTION = '1280x720x24'"); expect(defaultVirtualDisplay).toContain('class DefaultVirtualDisplay extends VirtualDisplay'); expect(defaultVirtualDisplay).toContain('patched[idx + 1] = DEFAULT_VIRTUAL_DISPLAY_RESOLUTION'); - expect(pluginContext).toContain('createVirtualDisplay: () => new DefaultVirtualDisplay()'); + expect(pluginContext).toContain('registerVirtualDisplayProvider: (pluginName, factory) => virtualDisplayRegistry.register(pluginName, factory)'); }); test('does not configure a fixed default browser context viewport', () => { diff --git a/tests/unit/pluginCapabilities.test.js b/tests/unit/pluginCapabilities.test.js new file mode 100644 index 0000000..e76a045 --- /dev/null +++ b/tests/unit/pluginCapabilities.test.js @@ -0,0 +1,22 @@ +import { describe, expect, test } from '@jest/globals'; +import { createVirtualDisplayRegistry } from '../../lib/plugin-capabilities.js'; + +describe('virtual display plugin capability', () => { + test('uses the registered plugin provider instead of the default provider', () => { + const registry = createVirtualDisplayRegistry(() => ({ owner: 'core' })); + + registry.register('vnc', () => ({ owner: 'vnc' })); + + expect(registry.owner).toBe('vnc'); + expect(registry.create()).toEqual({ owner: 'vnc' }); + }); + + test('rejects a second plugin provider instead of relying on load order', () => { + const registry = createVirtualDisplayRegistry(() => ({ owner: 'core' })); + registry.register('vnc', () => ({ owner: 'vnc' })); + + expect(() => registry.register('other-display', () => ({ owner: 'other' }))).toThrow( + '"vnc" already owns the capability; "other-display" cannot also provide it' + ); + }); +});