feat: add opt-in interactive desktop browser

This commit is contained in:
Pradeep Elankumaran
2026-08-19 12:45:32 -07:00
parent 0bacd019d3
commit 138614b517
6 changed files with 66 additions and 3 deletions
+19
View File
@@ -644,6 +644,7 @@ Browser behavior can be tuned in `camofox.config.json`:
| `MAX_TABS_PER_SESSION` | Max tabs per session | `10` |
| `SESSION_TIMEOUT_MS` | Session inactivity timeout | `1800000` (30min) |
| `BROWSER_IDLE_TIMEOUT_MS` | Kill browser when idle (0 = never) | `300000` (5min) |
| `CAMOFOX_INTERACTIVE` | Interactive browser mode: `desktop` opens a real local Camoufox window; `off` keeps normal headless behavior | `off` |
| `HANDLER_TIMEOUT_MS` | Max time for any handler | `30000` (30s) |
| `MAX_CONCURRENT_PER_USER` | Concurrent request cap per user | `3` |
| `MAX_OLD_SPACE_SIZE` | Node.js V8 heap limit (MB) | `128` |
@@ -666,6 +667,24 @@ Browser behavior can be tuned in `camofox.config.json`:
| `VNC_PASSWORD` | Password for VNC access (recommended in production) | - |
| `NOVNC_PORT` | noVNC web UI port | `6080` |
## Interactive desktop browser
Camofox is headless by default. On a machine with a local graphical desktop, opt in to a visible Camoufox window:
```bash
CAMOFOX_INTERACTIVE=desktop npm start
```
Or set the same preference in `camofox.config.json` and restart the server:
```json
{
"interactive": { "mode": "desktop" }
}
```
The environment variable overrides the file setting. Set `CAMOFOX_INTERACTIVE=off` to return to normal headless behavior. This mode is intended for a person using the same machine; it does not expose a remote browser-control service.
## Architecture
```
+1
View File
@@ -3,6 +3,7 @@
"name": "Camofox Browser",
"version": "1.6.0",
"newPageTimeoutMs": 10000,
"interactive": { "mode": "off" },
"plugins": {
"youtube": { "enabled": true },
"persistence": { "enabled": true },
+9
View File
@@ -76,6 +76,11 @@ function camoufoxExecutablePath(env = process.env) {
).trim();
}
function normalizeInteractiveMode(value) {
const mode = String(value || '').trim().toLowerCase();
return ['off', 'desktop', 'novnc', 'auto'].includes(mode) ? mode : 'off';
}
function loadConfig({ configPath = CONFIG_PATH } = {}) {
const externalCamoufoxExecutable = camoufoxExecutablePath();
const browserIdleTimeoutMs = parseInt(process.env.BROWSER_IDLE_TIMEOUT_MS, 10);
@@ -84,6 +89,8 @@ function loadConfig({ configPath = CONFIG_PATH } = {}) {
const newPageTimeoutMs = Number.isFinite(configuredNewPageTimeoutMs) && configuredNewPageTimeoutMs > 0
? configuredNewPageTimeoutMs
: 10000;
const configuredInteractiveMode = fileConfig.interactive?.mode;
const interactiveMode = normalizeInteractiveMode(process.env.CAMOFOX_INTERACTIVE || configuredInteractiveMode);
return {
port: parseInt(process.env.CAMOFOX_PORT || process.env.PORT || '9377', 10),
bindHost: (process.env.CAMOFOX_BIND_HOST || '').trim(),
@@ -118,6 +125,7 @@ function loadConfig({ configPath = CONFIG_PATH } = {}) {
camoufoxCacheDir: camoufoxCacheDir(),
prometheusEnabled: process.env.PROMETHEUS_ENABLED === '1' || process.env.PROMETHEUS_ENABLED === 'true',
disableDefaultAddons: process.env.CAMOFOX_DISABLE_DEFAULT_ADDONS === '1' || process.env.CAMOFOX_DISABLE_DEFAULT_ADDONS === 'true',
interactiveMode,
proxy: {
strategy: inferProxyStrategy(process.env.PROXY_STRATEGY || ''),
providerName: process.env.PROXY_PROVIDER || 'decodo',
@@ -153,6 +161,7 @@ function loadConfig({ configPath = CONFIG_PATH } = {}) {
CAMOFOX_TRACES_MAX_BYTES: process.env.CAMOFOX_TRACES_MAX_BYTES,
CAMOFOX_TRACES_TTL_HOURS: process.env.CAMOFOX_TRACES_TTL_HOURS,
CAMOFOX_DISABLE_DEFAULT_ADDONS: process.env.CAMOFOX_DISABLE_DEFAULT_ADDONS,
CAMOFOX_INTERACTIVE: process.env.CAMOFOX_INTERACTIVE,
CAMOUFOX_EXECUTABLE: process.env.CAMOUFOX_EXECUTABLE,
CAMOUFOX_EXECUTABLE_PATH: process.env.CAMOUFOX_EXECUTABLE_PATH,
CAMOFOX_EXECUTABLE_PATH: process.env.CAMOFOX_EXECUTABLE_PATH,
+5 -3
View File
@@ -1084,10 +1084,10 @@ async function launchBrowserInstance() {
let localVirtualDisplay = null;
let vdDisplay = undefined;
const useDesktopWindow = CONFIG.interactiveMode === 'desktop';
let candidateBrowser = null;
try {
if (os.platform() === 'linux') {
if (os.platform() === 'linux' && !useDesktopWindow) {
localVirtualDisplay = pluginCtx.createVirtualDisplay();
vdDisplay = await localVirtualDisplay.get();
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
@@ -1108,6 +1108,7 @@ async function launchBrowserInstance() {
proxySession: launchProxy?.sessionId || null,
proxyPoolSize: proxyPool?.size || 0,
virtualDisplay: useVirtualDisplay,
interactiveMode: CONFIG.interactiveMode,
});
try {
@@ -1118,7 +1119,7 @@ async function launchBrowserInstance() {
}
const options = await launchOptions({
executable_path: externalCamoufox?.executablePath,
headless: useVirtualDisplay ? false : true,
headless: useVirtualDisplay ? false : !useDesktopWindow,
os: hostOS,
humanize: true,
enable_cache: true,
@@ -1177,6 +1178,7 @@ async function launchBrowserInstance() {
attempt,
maxAttempts,
virtualDisplay: useVirtualDisplay,
interactiveMode: CONFIG.interactiveMode,
proxyMode: proxyPool?.mode || null,
proxyServer: launchProxy?.server || null,
proxySession: launchProxy?.sessionId || null,
+21
View File
@@ -82,6 +82,27 @@ describe('loadConfig', () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test('enables desktop interactive mode from the environment and forwards it to server subprocesses', () => {
process.env.CAMOFOX_INTERACTIVE = 'desktop';
const config = loadConfig();
expect(config.interactiveMode).toBe('desktop');
expect(config.serverEnv.CAMOFOX_INTERACTIVE).toBe('desktop');
});
test('reads interactive mode from camofox.config.json and rejects invalid modes', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'camofox-config-'));
const configPath = path.join(dir, 'camofox.config.json');
fs.writeFileSync(configPath, JSON.stringify({ interactive: { mode: 'desktop' } }));
expect(loadConfig({ configPath }).interactiveMode).toBe('desktop');
fs.writeFileSync(configPath, JSON.stringify({ interactive: { mode: 'surprise' } }));
expect(loadConfig({ configPath }).interactiveMode).toBe('off');
fs.rmSync(dir, { recursive: true, force: true });
});
test('disables default addons when CAMOFOX_DISABLE_DEFAULT_ADDONS is set', () => {
delete process.env.CAMOFOX_DISABLE_DEFAULT_ADDONS;
expect(loadConfig().disableDefaultAddons).toBe(false);
+11
View File
@@ -49,6 +49,17 @@ describe('launch compatibility source contract', () => {
expect(`${googleProbeOptions}\n${sessionContextOptions}`).not.toMatch(/viewport\s*:\s*\{\s*width\s*:/);
});
test('uses a real desktop window only when interactive desktop mode is explicit', () => {
const launch = sourceBetween(
'async function launchBrowserInstance()',
'async function ensureBrowser()'
);
expect(launch).toContain("const useDesktopWindow = CONFIG.interactiveMode === 'desktop'");
expect(launch).toContain("if (os.platform() === 'linux' && !useDesktopWindow)");
expect(launch).toContain('headless: useVirtualDisplay ? false : !useDesktopWindow');
});
test('health probe context also uses a null viewport', () => {
const healthProbeOptions = sourceBetween(
'testContext = await browser.newContext(',