mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
fix(plugins): scope virtual display providers
Thanks to @shauneccles for reporting #9527 and identifying the filesystem-order collision.
This commit is contained in:
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+12
-3
@@ -140,8 +140,8 @@ export function createPluginEvents() {
|
|||||||
* Load and register all plugins from plugins/<name>/index.js.
|
* Load and register all plugins from plugins/<name>/index.js.
|
||||||
*
|
*
|
||||||
* @param {object} app - Express app
|
* @param {object} app - Express app
|
||||||
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
|
* @param {object} ctx - Shared plugin context. Each registration receives `ctx.plugin`,
|
||||||
* Mutable -- plugins can replace ctx.createVirtualDisplay etc.
|
* containing that plugin's name, settings, and scoped capabilities.
|
||||||
* @returns {string[]} - Names of loaded plugins
|
* @returns {string[]} - Names of loaded plugins
|
||||||
*/
|
*/
|
||||||
export async function loadPlugins(app, ctx, options = {}) {
|
export async function loadPlugins(app, ctx, options = {}) {
|
||||||
@@ -191,10 +191,19 @@ export async function loadPlugins(app, ctx, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pluginConfig = pluginConfigs.get(name) || {};
|
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);
|
loaded.push(name);
|
||||||
ctx.log('info', 'plugin loaded', { plugin: name });
|
ctx.log('info', 'plugin loaded', { plugin: name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err?.code === 'plugin_capability_conflict') throw err;
|
||||||
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
|
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Disabled by default. Enable with `ENABLE_VNC=1` env var or `"vnc": { "enabled":
|
|||||||
|
|
||||||
## Architecture
|
## 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
|
## Original Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ noVNC / websockify (port 6080)
|
|||||||
Your browser → http://localhost:6080/vnc.html
|
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
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ import { removeXvfbDisplayFiles } from '../../lib/tmp-cleanup.js';
|
|||||||
|
|
||||||
export async function register(app, ctx, pluginConfig = {}) {
|
export async function register(app, ctx, pluginConfig = {}) {
|
||||||
const { events, config, log, sessions, VirtualDisplay, safeError } = ctx;
|
const { events, config, log, sessions, VirtualDisplay, safeError } = ctx;
|
||||||
|
const settings = ctx.plugin?.settings || pluginConfig;
|
||||||
|
|
||||||
// Resolve all config (env vars + pluginConfig) via the launcher module
|
// Resolve all config (env vars + plugin settings) via the launcher module
|
||||||
const vncConfig = resolveVncConfig(pluginConfig);
|
const vncConfig = resolveVncConfig(settings);
|
||||||
|
|
||||||
if (!vncConfig.enabled) {
|
if (!vncConfig.enabled) {
|
||||||
log('info', 'vnc plugin: disabled (set ENABLE_VNC=1 or plugins.vnc.enabled=true)');
|
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();
|
ctx.plugin.registerVirtualDisplayProvider(() => new VncVirtualDisplay());
|
||||||
log('info', 'vnc plugin: overriding Xvfb resolution', { resolution });
|
log('info', 'vnc plugin: registered Xvfb display provider', { resolution });
|
||||||
|
|
||||||
// --- VNC watcher process ---
|
// --- VNC watcher process ---
|
||||||
log('info', 'vnc plugin enabled', {
|
log('info', 'vnc plugin enabled', {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe('vnc plugin', () => {
|
|||||||
mockApp = {
|
mockApp = {
|
||||||
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
|
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
|
||||||
};
|
};
|
||||||
|
const displayProvider = { create: () => new MockVirtualDisplay() };
|
||||||
ctx = {
|
ctx = {
|
||||||
events,
|
events,
|
||||||
config: {},
|
config: {},
|
||||||
@@ -77,8 +78,11 @@ describe('vnc plugin', () => {
|
|||||||
sessions: new Map(),
|
sessions: new Map(),
|
||||||
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
|
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
|
||||||
VirtualDisplay: MockVirtualDisplay,
|
VirtualDisplay: MockVirtualDisplay,
|
||||||
createVirtualDisplay: () => new MockVirtualDisplay(),
|
plugin: {
|
||||||
|
registerVirtualDisplayProvider: (factory) => { displayProvider.create = factory; },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
ctx.createVirtualDisplay = () => displayProvider.create();
|
||||||
mockStartWatcher.mockClear();
|
mockStartWatcher.mockClear();
|
||||||
mockStartWatcher.mockImplementation(mockWatcher);
|
mockStartWatcher.mockImplementation(mockWatcher);
|
||||||
mockResolveVncConfig.mockClear();
|
mockResolveVncConfig.mockClear();
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { createReporter, createTabHealthTracker, collectResourceSnapshot, classi
|
|||||||
import { mountDocs } from './lib/openapi.js';
|
import { mountDocs } from './lib/openapi.js';
|
||||||
import { initSentry, captureException as sentryCaptureException, setupExpressErrorHandler as setupSentryErrorHandler, flush as sentryFlush } from './lib/sentry.js';
|
import { initSentry, captureException as sentryCaptureException, setupExpressErrorHandler as setupSentryErrorHandler, flush as sentryFlush } from './lib/sentry.js';
|
||||||
import { prepareExternalCamoufoxExecutable } from './lib/camoufox-executable.js';
|
import { prepareExternalCamoufoxExecutable } from './lib/camoufox-executable.js';
|
||||||
|
import { createVirtualDisplayRegistry } from './lib/plugin-capabilities.js';
|
||||||
import { killProcessIds } from './lib/browser-processes.js';
|
import { killProcessIds } from './lib/browser-processes.js';
|
||||||
import { snapshotOwnedBrowserProcesses, survivingOwnedBrowserProcesses, profilePathsFromProcessSnapshot } from './lib/process-ownership.js';
|
import { snapshotOwnedBrowserProcesses, survivingOwnedBrowserProcesses, profilePathsFromProcessSnapshot } from './lib/process-ownership.js';
|
||||||
import {
|
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 || ''));
|
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) {
|
async function buildLaunchOptionsWithGeoipFallback(baseOptions, attemptMeta) {
|
||||||
try {
|
try {
|
||||||
return await withTimeout(launchOptions(baseOptions), GEOIP_SETUP_TIMEOUT_MS, 'GeoIP setup');
|
return await withTimeout(launchOptions(baseOptions), GEOIP_SETUP_TIMEOUT_MS, 'GeoIP setup');
|
||||||
@@ -1114,7 +1117,7 @@ async function launchBrowserInstance() {
|
|||||||
let candidateBrowser = null;
|
let candidateBrowser = null;
|
||||||
try {
|
try {
|
||||||
if (os.platform() === 'linux' && !useDesktopWindow) {
|
if (os.platform() === 'linux' && !useDesktopWindow) {
|
||||||
localVirtualDisplay = pluginCtx.createVirtualDisplay();
|
localVirtualDisplay = virtualDisplayRegistry.create();
|
||||||
vdDisplay = await localVirtualDisplay.get();
|
vdDisplay = await localVirtualDisplay.get();
|
||||||
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
|
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
|
||||||
}
|
}
|
||||||
@@ -7012,8 +7015,7 @@ const pluginCtx = {
|
|||||||
failuresTotal,
|
failuresTotal,
|
||||||
metricsRegistry: getRegister,
|
metricsRegistry: getRegister,
|
||||||
createMetric,
|
createMetric,
|
||||||
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
|
registerVirtualDisplayProvider: (pluginName, factory) => virtualDisplayRegistry.register(pluginName, factory),
|
||||||
createVirtualDisplay: () => new DefaultVirtualDisplay(),
|
|
||||||
/** The upstream VirtualDisplay class -- plugins can subclass it. */
|
/** The upstream VirtualDisplay class -- plugins can subclass it. */
|
||||||
VirtualDisplay,
|
VirtualDisplay,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('launch compatibility source contract', () => {
|
|||||||
expect(defaultVirtualDisplay).toContain("DEFAULT_VIRTUAL_DISPLAY_RESOLUTION = '1280x720x24'");
|
expect(defaultVirtualDisplay).toContain("DEFAULT_VIRTUAL_DISPLAY_RESOLUTION = '1280x720x24'");
|
||||||
expect(defaultVirtualDisplay).toContain('class DefaultVirtualDisplay extends VirtualDisplay');
|
expect(defaultVirtualDisplay).toContain('class DefaultVirtualDisplay extends VirtualDisplay');
|
||||||
expect(defaultVirtualDisplay).toContain('patched[idx + 1] = DEFAULT_VIRTUAL_DISPLAY_RESOLUTION');
|
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', () => {
|
test('does not configure a fixed default browser context viewport', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user