diff --git a/Dockerfile b/Dockerfile index 1de5fb5c..e612d2ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \ libcairo2 libasound2 libx11-xcb1 libfontconfig1 libx11-6 \ libxcb1 libxext6 libxshmfence1 \ + libglvnd0 libegl1 libgles2 libglx0 libopengl0 \ libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \ libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \ fonts-noto-color-emoji fonts-unifont fonts-freefont-ttf \ diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 9c522310..60f44a72 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -176,6 +176,7 @@ def launch( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -226,7 +227,7 @@ def launch( args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) - chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, gpu_accel=gpu_accel, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize) _maybe_warn_windows_fonts(chrome_args) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) @@ -298,6 +299,7 @@ async def launch_async( # noqa: C901 human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -345,7 +347,7 @@ async def launch_async( # noqa: C901 proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) - chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, gpu_accel=gpu_accel, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize) _maybe_warn_windows_fonts(chrome_args) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) @@ -419,6 +421,7 @@ def launch_persistent_context( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -475,7 +478,7 @@ def launch_persistent_context( proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) - chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, gpu_accel=gpu_accel, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) _maybe_warn_windows_fonts(chrome_args) logger.debug( @@ -568,6 +571,7 @@ async def launch_persistent_context_async( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -626,7 +630,7 @@ async def launch_persistent_context_async( proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) - chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, gpu_accel=gpu_accel, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) _maybe_warn_windows_fonts(chrome_args) logger.debug( @@ -718,6 +722,7 @@ def launch_context( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -764,6 +769,7 @@ def launch_context( # locale and timezone are set via binary flags only — no CDP emulation. browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, timezone=timezone, locale=locale, extension_paths=extension_paths, + gpu_accel=gpu_accel, license_key=license_key, browser_version=browser_version, release_channel=release_channel, # Caller chose a viewport geometry → don't also auto-maximize @@ -825,6 +831,7 @@ async def launch_context_async( human_preset: HumanPreset = "default", human_config: HumanConfigOverrides | None = None, extension_paths: list[str] | None = None, + gpu_accel: bool = False, license_key: str | None = None, browser_version: str | None = None, release_channel: str | None = None, @@ -890,6 +897,7 @@ async def launch_context_async( # locale and timezone are set via binary flags only — no CDP emulation. browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, timezone=timezone, locale=locale, extension_paths=extension_paths, + gpu_accel=gpu_accel, license_key=license_key, browser_version=browser_version, release_channel=release_channel, # Caller chose a viewport geometry → don't also auto-maximize @@ -1192,6 +1200,7 @@ def build_args( locale: str | None = None, headless: bool = True, extension_paths: list[str] | None = None, + gpu_accel: bool = False, start_maximized: bool = False, ) -> list[str]: """Combine stealth args with user-provided args and locale flags. @@ -1215,6 +1224,18 @@ def build_args( if not headless or _platform.system() == "Windows": seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist" + # Opt-in Docker GPU acceleration (gpu_accel=True / CLOAKBROWSER_GPU_ACCEL=1). + # ANGLE over native EGL (--use-angle=gl-egl) is what actually engages the GPU on + # modern Chromium; the legacy --use-gl=egl value was removed and silently + # disables WebGL. Needs the GLVND EGL loader present (Docker image ships it). + if gpu_accel or os.environ.get("CLOAKBROWSER_GPU_ACCEL"): + seen["--use-gl"] = "--use-gl=angle" + seen["--use-angle"] = "--use-angle=gl-egl" + seen["--enable-gpu-rasterization"] = "--enable-gpu-rasterization" + seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist" + if sys.platform.startswith("linux"): + seen["--enable-features"] = "--enable-features=VaapiVideoDecoder" + if extra_args: for arg in extra_args: key = arg.split("=", 1)[0] diff --git a/examples/docker-compose.gpu.yml b/examples/docker-compose.gpu.yml new file mode 100644 index 00000000..904dff11 --- /dev/null +++ b/examples/docker-compose.gpu.yml @@ -0,0 +1,34 @@ +# Opt-in GPU acceleration for CloakBrowser in Docker. Requires the NVIDIA +# Container Toolkit on the host so the container can see the GPU. +# +# CLOAKBROWSER_GPU_ACCEL=1 adds the ANGLE/EGL + GPU-rasterization + VA-API flags. +# GPU acceleration engages for HEADLESS Chrome launched via the library API +# (Python launch(gpu_accel=True) / JS launch({ gpuAccel: true })), which uses a +# surfaceless EGL context — verify with chrome://gpu or CDP SystemInfo.getInfo +# (expect your GPU's renderer, e.g. "ANGLE (NVIDIA ... OpenGL ES ...)", not +# SwiftShader/llvmpipe). Point `command` at your own headless workload; the +# cloakserve line below is only a long-running placeholder for the healthcheck. +services: + cloakbrowser: + image: cloakhq/cloakbrowser + command: cloakserve + restart: unless-stopped + environment: + CLOAKBROWSER_GPU_ACCEL: "1" + # Expose the graphics + video driver libs to the container (not just compute). + NVIDIA_DRIVER_CAPABILITIES: all + ports: + - "127.0.0.1:9222:9222" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/js/src/args.ts b/js/src/args.ts index da769a5a..eed48c20 100644 --- a/js/src/args.ts +++ b/js/src/args.ts @@ -29,6 +29,18 @@ export function buildArgs(options: LaunchOptions): string[] { if (options.headless === false || process.platform === "win32") { seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist"); } + // Opt-in Docker GPU acceleration (gpuAccel: true / CLOAKBROWSER_GPU_ACCEL=1). + // ANGLE over native EGL (--use-angle=gl-egl) engages the real GPU on modern + // Chromium; the legacy --use-gl=egl value was removed and silently disables WebGL. + if (options.gpuAccel || process.env.CLOAKBROWSER_GPU_ACCEL) { + seen.set("--use-gl", "--use-gl=angle"); + seen.set("--use-angle", "--use-angle=gl-egl"); + seen.set("--enable-gpu-rasterization", "--enable-gpu-rasterization"); + seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist"); + if (process.platform === "linux") { + seen.set("--enable-features", "--enable-features=VaapiVideoDecoder"); + } + } if (options.args) { for (const arg of options.args) { const key = arg.split("=")[0]; diff --git a/js/src/types.ts b/js/src/types.ts index 2f10b366..a0558a51 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -27,6 +27,8 @@ export interface LaunchOptions { locale?: string; /** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */ geoip?: boolean; + /** Opt-in Docker GPU acceleration flags. Also reads CLOAKBROWSER_GPU_ACCEL=1. */ + gpuAccel?: boolean; /** Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. */ licenseKey?: string; /** Exact Chromium version pin. Also reads from CLOAKBROWSER_VERSION env var. */ diff --git a/js/tests/config.test.ts b/js/tests/config.test.ts index 182f8b8f..7017d76b 100644 --- a/js/tests/config.test.ts +++ b/js/tests/config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; import { CHROMIUM_VERSION, getArchiveExt, @@ -15,6 +15,53 @@ import { } from "../src/config.js"; import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js"; +describe("buildArgs GPU acceleration (issue #189)", () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origEnv = process.env.CLOAKBROWSER_GPU_ACCEL; + const setPlatform = (p: NodeJS.Platform) => + Object.defineProperty(process, "platform", { ...origPlatform, value: p }); + afterEach(() => { + Object.defineProperty(process, "platform", origPlatform); + if (origEnv === undefined) delete process.env.CLOAKBROWSER_GPU_ACCEL; + else process.env.CLOAKBROWSER_GPU_ACCEL = origEnv; + }); + + it("leaves GPU flags off by default", () => { + delete process.env.CLOAKBROWSER_GPU_ACCEL; + const args = _buildArgsForTest({}); + expect(args).not.toContain("--use-angle=gl-egl"); + expect(args).not.toContain("--enable-gpu-rasterization"); + }); + + it("CLOAKBROWSER_GPU_ACCEL adds ANGLE-over-EGL flags (not the removed use-gl=egl)", () => { + process.env.CLOAKBROWSER_GPU_ACCEL = "1"; + setPlatform("linux"); + const args = _buildArgsForTest({}); + expect(args).toContain("--use-gl=angle"); + expect(args).toContain("--use-angle=gl-egl"); + expect(args).not.toContain("--use-gl=egl"); + expect(args).toContain("--enable-gpu-rasterization"); + expect(args).toContain("--ignore-gpu-blocklist"); + expect(args).toContain("--enable-features=VaapiVideoDecoder"); + }); + + it("skips VaapiVideoDecoder off Linux", () => { + process.env.CLOAKBROWSER_GPU_ACCEL = "1"; + setPlatform("darwin"); + const args = _buildArgsForTest({}); + expect(args).toContain("--use-angle=gl-egl"); + expect(args).not.toContain("--enable-features=VaapiVideoDecoder"); + }); + + it("gpuAccel option works without the environment variable", () => { + delete process.env.CLOAKBROWSER_GPU_ACCEL; + setPlatform("linux"); + const args = _buildArgsForTest({ gpuAccel: true }); + expect(args).toContain("--use-angle=gl-egl"); + expect(args).toContain("--enable-features=VaapiVideoDecoder"); + }); +}); + describe("config", () => { it("CHROMIUM_VERSION matches expected format", () => { expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+(\.\d+)?$/); diff --git a/tests/conftest.py b/tests/conftest.py index efc359cb..e45a26eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,9 @@ def isolated_cache_dir(tmp_path_factory, monkeypatch): "CLOAKBROWSER_CACHE_DIR", str(tmp_path_factory.mktemp("cloakbrowser-cache")), ) + + +@pytest.fixture(autouse=True) +def _clear_gpu_accel_env(monkeypatch): + """A host-level CLOAKBROWSER_GPU_ACCEL must not leak into build_args tests.""" + monkeypatch.delenv("CLOAKBROWSER_GPU_ACCEL", raising=False) diff --git a/tests/test_build_args.py b/tests/test_build_args.py index e6823c01..b31243e2 100644 --- a/tests/test_build_args.py +++ b/tests/test_build_args.py @@ -1,5 +1,7 @@ """Unit tests for build_args timezone/locale injection and timezone alias.""" +import sys + from cloakbrowser.browser import build_args, _resolve_timezone @@ -151,6 +153,50 @@ def test_non_value_flags_preserved(): assert "--no-sandbox" in args +# --- GPU acceleration (issue #189) --- + + +def test_gpu_accel_default_off(monkeypatch): + """GPU flags stay off unless explicitly enabled.""" + monkeypatch.delenv("CLOAKBROWSER_GPU_ACCEL", raising=False) + args = build_args(stealth_args=True, extra_args=None) + assert "--use-angle=gl-egl" not in args + assert "--enable-gpu-rasterization" not in args + assert "--enable-features=VaapiVideoDecoder" not in args + + +def test_gpu_accel_env_adds_linux_flags(monkeypatch): + """CLOAKBROWSER_GPU_ACCEL enables ANGLE-over-EGL GPU flags (+ Vaapi on Linux).""" + monkeypatch.setenv("CLOAKBROWSER_GPU_ACCEL", "1") + monkeypatch.setattr(sys, "platform", "linux") + args = build_args(stealth_args=True, extra_args=None) + assert "--use-gl=angle" in args + assert "--use-angle=gl-egl" in args + assert "--use-gl=egl" not in args # obsolete value must never reappear (#189) + assert "--enable-gpu-rasterization" in args + assert "--ignore-gpu-blocklist" in args + assert "--enable-features=VaapiVideoDecoder" in args + + +def test_gpu_accel_skips_vaapi_off_linux(monkeypatch): + """VaapiVideoDecoder is Linux-only; the ANGLE flags still apply elsewhere.""" + monkeypatch.setenv("CLOAKBROWSER_GPU_ACCEL", "1") + monkeypatch.setattr(sys, "platform", "darwin") + args = build_args(stealth_args=True, extra_args=None) + assert "--use-angle=gl-egl" in args + assert "--enable-features=VaapiVideoDecoder" not in args + + +def test_gpu_accel_kwarg_adds_flags(monkeypatch): + """gpu_accel=True enables the GPU flags without the environment variable.""" + monkeypatch.delenv("CLOAKBROWSER_GPU_ACCEL", raising=False) + monkeypatch.setattr(sys, "platform", "linux") + args = build_args(stealth_args=True, extra_args=None, gpu_accel=True) + assert "--use-gl=angle" in args + assert "--use-angle=gl-egl" in args + assert "--enable-features=VaapiVideoDecoder" in args + + def test_override_logs_debug(caplog): """Should log debug message when an override happens.""" import logging diff --git a/tests/test_launch_context.py b/tests/test_launch_context.py index 378e2b95..2e3cb8aa 100644 --- a/tests/test_launch_context.py +++ b/tests/test_launch_context.py @@ -46,6 +46,33 @@ def test_release_channel_forwarded(mock_launch, _mock_bin): assert mock_launch.call_args.kwargs["release_channel"] == "preview" +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_gpu_accel_forwarded_to_launch(mock_launch, _mock_bin): + """launch_context(gpu_accel=True) forwards to launch(), not new_context().""" + browser, _ = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(gpu_accel=True) + + assert mock_launch.call_args.kwargs.get("gpu_accel") is True + assert "gpu_accel" not in browser.new_context.call_args.kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_gpu_accel_default_false(mock_launch, _mock_bin): + """launch_context() defaults gpu_accel=False into launch().""" + browser, _ = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context() + + assert mock_launch.call_args.kwargs.get("gpu_accel") is False + + @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.launch") def test_headed_no_viewport(mock_launch, _mock_bin):