diff --git a/README.md b/README.md index 5935d07..532e086 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,102 @@ Anti-detection browser integration for Agent Zero. Provides: - Optional: `camofox` CLI for auth vault and scripting - For visible browser mode: `xvfb`, `x11vnc`, `websockify`, and noVNC assets at `/opt/noVNC` - The embedded viewer now runs through the normal Agent Zero URL, so users do not need to publish extra VNC ports in Docker + +--- + +## Virtual Display Mode (Xvfb) + +When running in Docker or headless Linux environments, CamoFox can use a virtual display via Xvfb for the VNC panel. Key points: + +- CamoFox spawns its **own Xvfb displays** (`:100`, `:101`) in virtual mode — separate from any manually started display +- `x11vnc` must be pointed at the **CamoFox display**, not the base `:99` display +- The VNC watchdog script (`scripts/vnc-watchdog.sh`) handles this automatically + +### Startup Sequence + +```bash +# 1. Start base Xvfb display +nohup Xvfb :99 -screen 0 1920x1080x24 -ac -nolisten tcp > /tmp/xvfb.log 2>&1 & + +# 2. Start websockify +nohup /usr/bin/python3 /usr/bin/websockify --web /opt/noVNC 6080 127.0.0.1:5999 > /tmp/websockify.log 2>&1 & + +# 3. Start CamoFox server with software rendering env vars +DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ +MOZ_DISABLE_OOP_COMPOSITING=1 MOZ_WEBRENDER=0 MOZ_DISABLE_RDD_SANDBOX=1 \ +nohup node /usr/local/lib/node_modules/camofox-browser/dist/src/server.js > /tmp/camofox.log 2>&1 & + +# 4. Toggle to virtual mode via Agent Zero tool: camofox_session toggle_display headless=virtual + +# 5. Start VNC watchdog (auto-detects correct display, keeps x11vnc alive) +bash /path/to/camofox-plugin/scripts/vnc-watchdog.sh >> /tmp/camofox-watchdog.log 2>&1 & +``` + +--- + +## Google OAuth / Popup Windows in Xvfb + +When using Google OAuth ("Sign in with Google") or any site that opens a popup window, the popup may render as a **black screen** in Xvfb virtual display environments. This is caused by GPU-accelerated compositing being unavailable in headless environments. + +### Fix: Apply Firefox User Preferences + +A `user.js` template is included in `scripts/user.js.template`. Apply it to your CamoFox browser profile: + +```bash +# Find your profile directory +ls /root/.camofox/profiles/ + +# Copy the template (replace 'a0-agent-0' with your profile name) +cp scripts/user.js.template /root/.camofox/profiles/a0-agent-0/user.js + +# Restart the browser session to apply +``` + +This template: +- **Disables GPU/WebRender acceleration** — fixes black rendering in Xvfb +- **Forces popup windows to open as tabs** (`browser.link.open_newwindow=3`) — prevents Google OAuth and similar popups from opening as separate black windows; they open as normal tabs instead + +### Additional: Software Rendering Environment Variables + +The plugin's Start button (and the startup sequence above) automatically sets these environment variables on the CamoFox server process, which are inherited by the browser: + +``` +LIBGL_ALWAYS_SOFTWARE=1 +GALLIUM_DRIVER=llvmpipe +MOZ_DISABLE_OOP_COMPOSITING=1 +MOZ_WEBRENDER=0 +MOZ_DISABLE_RDD_SANDBOX=1 +``` + +### Closing a Stuck Black Popup + +If a black popup is already open and can't be closed through the browser UI: + +```bash +# Install xdotool if missing +apt-get install -y xdotool + +# Find and close popups on the CamoFox display +for wid in $(DISPLAY=:100 xdotool search --name '' 2>/dev/null); do + name=$(DISPLAY=:100 xdotool getwindowname $wid 2>/dev/null) + if echo "$name" | grep -qi 'google\|sign in'; then + DISPLAY=:100 xdotool windowclose $wid 2>/dev/null && echo "Closed: $name" + fi +done +``` + +--- + +## Scripts + +### `scripts/vnc-watchdog.sh` + +A background watchdog that monitors which display `camoufox-bin` is rendering on and auto-restarts `x11vnc` on the correct display whenever it crashes or changes. Run it after toggling to virtual display mode: + +```bash +bash scripts/vnc-watchdog.sh >> /tmp/camofox-watchdog.log 2>&1 & +``` + +### `scripts/user.js.template` + +Firefox user preferences template for fixing popup rendering in Xvfb environments. See [Google OAuth / Popup Windows in Xvfb](#google-oauth--popup-windows-in-xvfb) above. diff --git a/api/camofox_health.py b/api/camofox_health.py index 3f5944b..d02e767 100644 --- a/api/camofox_health.py +++ b/api/camofox_health.py @@ -1,5 +1,6 @@ """API handler: CamoFox server health check, start, and stop.""" +import asyncio import os import shutil @@ -151,6 +152,7 @@ async def _diagnose(self) -> dict: async def _server_control(self, action: str) -> dict: cfg = get_config() + # Try CamofoxCli first; fall back to direct subprocess control try: cli = CamofoxCli( binary_path=cfg.get("binary_path") or None, @@ -158,7 +160,76 @@ async def _server_control(self, action: str) -> dict: ) result = await cli.execute("server", action) return {"ok": True, "action": action, "result": result} - except CamofoxCliNotFoundError as e: - return {"ok": False, "error": str(e)} - except CamofoxCliError as e: - return {"ok": False, "error": str(e)} + except (CamofoxCliNotFoundError, CamofoxCliError): + pass # Fall through to subprocess fallback + except Exception: + pass + + # Subprocess fallback (no camofox CLI required) + if action == "stop": + try: + proc = await asyncio.create_subprocess_exec( + "pkill", "-f", "node.*server.js", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + # Also kill any lingering camoufox-bin processes + proc2 = await asyncio.create_subprocess_exec( + "pkill", "-f", "camoufox-bin", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc2.communicate() + return {"ok": True, "action": "stop", "result": "Server stopped via pkill."} + except Exception as e: + return {"ok": False, "error": f"Stop failed: {e}"} + + elif action == "start": + import shutil as _shutil + # Find server.js + server_js = None + try: + result = await asyncio.create_subprocess_exec( + "npm", "root", "-g", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await result.communicate() + if result.returncode == 0: + candidate = os.path.join(stdout.decode().strip(), "camofox-browser", "dist", "src", "server.js") + if os.path.isfile(candidate): + server_js = candidate + except Exception: + pass + + if not server_js: + return {"ok": False, "error": "server.js not found — cannot start server."} + + port = cfg.get("server_url", "http://localhost:9377").split(":")[-1].rstrip("/") + env = os.environ.copy() + env["CAMOFOX_PORT"] = str(port) + if cfg.get("api_key"): + env["CAMOFOX_API_KEY"] = cfg["api_key"] + if cfg.get("admin_key"): + env["CAMOFOX_ADMIN_KEY"] = cfg["admin_key"] + # Software rendering env vars + env["LIBGL_ALWAYS_SOFTWARE"] = "1" + env["GALLIUM_DRIVER"] = "llvmpipe" + env["MOZ_DISABLE_OOP_COMPOSITING"] = "1" + env["MOZ_WEBRENDER"] = "0" + env["MOZ_DISABLE_RDD_SANDBOX"] = "1" + + try: + await asyncio.create_subprocess_exec( + "node", server_js, + env=env, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.sleep(4) + return {"ok": True, "action": "start", "result": "Server start command sent."} + except Exception as e: + return {"ok": False, "error": f"Start failed: {e}"} + + return {"ok": False, "error": f"Unknown action: {action!r}"} diff --git a/api/camofox_setup.py b/api/camofox_setup.py index a0108e8..0d9c65d 100644 --- a/api/camofox_setup.py +++ b/api/camofox_setup.py @@ -128,6 +128,12 @@ async def _start_server(self, input: dict) -> dict: env["CAMOFOX_API_KEY"] = api_key if admin_key: env["CAMOFOX_ADMIN_KEY"] = admin_key + # Software rendering — required for correct rendering in Xvfb virtual displays + env["LIBGL_ALWAYS_SOFTWARE"] = "1" + env["GALLIUM_DRIVER"] = "llvmpipe" + env["MOZ_DISABLE_OOP_COMPOSITING"] = "1" + env["MOZ_WEBRENDER"] = "0" + env["MOZ_DISABLE_RDD_SANDBOX"] = "1" # Find server.js from installed npm package server_js = self._find_server_js() diff --git a/api/camofox_startup.py b/api/camofox_startup.py new file mode 100644 index 0000000..116774c --- /dev/null +++ b/api/camofox_startup.py @@ -0,0 +1,431 @@ +"""API handler: CamoFox complete stack startup and shutdown. + +This handler manages the FULL CamoFox+VNC stack lifecycle: + - Start: Node.js server + virtual display toggle + x11vnc watchdog + - Stop: kill server + browser + x11vnc + watchdog + - Status: comprehensive health of all stack components + +This is the proper solution vs. patching individual pieces. +""" + +import asyncio +import os +import shutil +import subprocess +from pathlib import Path + +from helpers.api import ApiHandler, Request +from usr.plugins.camofox_browser.helpers.config import get_config, normalize_headless_mode +from usr.plugins.camofox_browser.helpers.client import CamofoxClient, CamofoxConnectionError + + +SERVER_JS_SEARCH = [ + "/usr/local/lib/node_modules/camofox-browser/dist/src/server.js", + "/usr/lib/node_modules/camofox-browser/dist/src/server.js", +] + +def _get_vnc_resolution(): + """Get VNC resolution from config, returns (width, height, depth_str).""" + cfg = get_config() + res = cfg.get("vnc_resolution", "1280x800") + parts = res.lower().split("x") + w = int(parts[0]) if len(parts) >= 1 else 1280 + h = int(parts[1]) if len(parts) >= 2 else 800 + return w, h, f"{w}x{h}x24" + +SOFTWARE_RENDERING_ENV = { + "LIBGL_ALWAYS_SOFTWARE": "1", + "GALLIUM_DRIVER": "llvmpipe", + "MOZ_DISABLE_OOP_COMPOSITING": "1", + "MOZ_WEBRENDER": "0", + "MOZ_DISABLE_RDD_SANDBOX": "1", + "DISPLAY": ":99", +} + +WATCHDOG_SCRIPT = str(Path(__file__).parent.parent / "scripts" / "vnc-watchdog.sh") + + +class CamofoxStartup(ApiHandler): + """Full CamoFox+VNC stack lifecycle management. + + Input: {"action": "start" | "stop" | "status" | "restart"} + """ + + @classmethod + def requires_auth(cls) -> bool: + return True + + async def process(self, input: dict, request: Request) -> dict: + action = input.get("action", "status") + + if action == "start": + return await self._full_start() + elif action == "stop": + return await self._full_stop() + elif action == "restart": + await self._full_stop() + await asyncio.sleep(2) + return await self._full_start() + elif action == "status": + return await self._stack_status() + else: + return {"ok": False, "error": f"Unknown action: {action!r}"} + + def _find_server_js(self) -> str | None: + for path in SERVER_JS_SEARCH: + if os.path.isfile(path): + return path + # Try npm root -g + try: + result = subprocess.run( + ["npm", "root", "-g"], capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + path = os.path.join( + result.stdout.strip(), "camofox-browser", "dist", "src", "server.js" + ) + if os.path.isfile(path): + return path + except Exception: + pass + return None + + def _is_server_running(self) -> bool: + return bool(subprocess.run( + ["pgrep", "-f", "node.*server.js"], + capture_output=True + ).returncode == 0) + + def _is_watchdog_running(self) -> bool: + return bool(subprocess.run( + ["pgrep", "-f", "vnc-watchdog"], + capture_output=True + ).returncode == 0) + + def _is_x11vnc_running(self) -> bool: + return bool(subprocess.run( + ["pgrep", "-f", "x11vnc"], + capture_output=True + ).returncode == 0) + + def _is_xvfb_running(self) -> bool: + return bool(subprocess.run( + ["pgrep", "-f", "Xvfb"], + capture_output=True + ).returncode == 0) + + def _is_websockify_running(self) -> bool: + return bool(subprocess.run( + ["pgrep", "-f", "websockify"], + capture_output=True + ).returncode == 0) + + async def _ensure_xvfb(self) -> bool: + """Ensure Xvfb :99 is running.""" + if self._is_xvfb_running(): + return True + try: + _, _, res_str = _get_vnc_resolution() + await asyncio.create_subprocess_exec( + "Xvfb", ":99", "-screen", "0", res_str, "-ac", "-nolisten", "tcp", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.sleep(2) + return self._is_xvfb_running() + except Exception: + return False + + async def _ensure_websockify(self) -> bool: + """Start a FRESH single websockify instance, killing any stale ones first. + + Always restarts websockify to avoid stale/frozen connections where the + noVNC client is connected to an old websockify that lost its x11vnc link. + """ + novnc = "/opt/noVNC" + if not os.path.isdir(novnc): + return self._is_websockify_running() # Can't manage it, just check + websockify_bin = shutil.which("websockify") + if not websockify_bin: + return self._is_websockify_running() + # Always kill stale instances first (use -9 to ensure clean kill) + subprocess.run(["pkill", "-9", "-f", "websockify"], capture_output=True) + await asyncio.sleep(2) # Wait for port 6080 to be released + try: + await asyncio.create_subprocess_exec( + "python3", websockify_bin, "--web", novnc, "6080", "127.0.0.1:5999", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.sleep(3) + return self._is_websockify_running() + except Exception: + return False + + async def _ensure_server(self) -> bool: + """Ensure CamoFox Node.js server is running with software rendering.""" + if self._is_server_running(): + return True + server_js = self._find_server_js() + if not server_js: + return False + cfg = get_config() + env = os.environ.copy() + env.update(SOFTWARE_RENDERING_ENV) + _, _, res_str = _get_vnc_resolution() + env["CAMOFOX_VNC_RESOLUTION"] = res_str + port = cfg.get("server_url", "http://localhost:9377").split(":")[-1].rstrip("/") + env["CAMOFOX_PORT"] = str(port) + env["CAMOFOX_HEADLESS"] = normalize_headless_mode(cfg.get("default_headless", True)) + if cfg.get("api_key"): + env["CAMOFOX_API_KEY"] = cfg["api_key"] + if cfg.get("admin_key"): + env["CAMOFOX_ADMIN_KEY"] = cfg["admin_key"] + try: + await asyncio.create_subprocess_exec( + "node", server_js, + env=env, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + # Wait for server to be ready + for _ in range(10): + await asyncio.sleep(1) + try: + cfg2 = get_config() + client = CamofoxClient( + base_url=cfg2["server_url"], + api_key=cfg2.get("api_key", ""), + admin_key=cfg2.get("admin_key", ""), + ) + await client.get("/health") + await client.close() + return True + except Exception: + pass + except Exception: + pass + return False + + async def _toggle_to_virtual(self) -> bool: + """Force the browser into virtual display mode via the Node.js API.""" + cfg = get_config() + client = CamofoxClient( + base_url=cfg["server_url"], + api_key=cfg.get("api_key", ""), + admin_key=cfg.get("admin_key", ""), + ) + try: + # Step 1: Force headless to sync toggle state + await client.post("/sessions/a0-agent-0/toggle-display", data={"headless": True}) + await asyncio.sleep(1) + # Step 2: Toggle to virtual + result = await client.post("/sessions/a0-agent-0/toggle-display", data={"headless": "virtual"}) + await asyncio.sleep(4) # Wait for camoufox-bin to spawn + return True + except Exception: + return False + finally: + await client.close() + + async def _start_watchdog(self) -> bool: + """Start the VNC watchdog script.""" + if self._is_watchdog_running(): + return True + if not os.path.isfile(WATCHDOG_SCRIPT): + return False + try: + await asyncio.create_subprocess_exec( + "bash", WATCHDOG_SCRIPT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.sleep(5) # Give watchdog time to detect display and start x11vnc + return True + except Exception: + return False + + async def _full_start(self) -> dict: + """Start the complete CamoFox+VNC stack.""" + steps = {} + + # 1. Xvfb + xvfb_ok = await self._ensure_xvfb() + steps["xvfb"] = {"ok": xvfb_ok, "msg": "running" if xvfb_ok else "failed to start"} + + # 2. Websockify + ws_ok = await self._ensure_websockify() + steps["websockify"] = {"ok": ws_ok, "msg": "running" if ws_ok else "failed or not installed"} + + # 3. CamoFox Node.js server with software rendering + server_ok = await self._ensure_server() + steps["server"] = {"ok": server_ok, "msg": "running" if server_ok else "failed to start"} + + if not server_ok: + return { + "ok": False, + "error": "CamoFox server failed to start. Check that Node.js and camofox-browser npm package are installed.", + "steps": steps, + } + + # 4. Toggle to virtual display mode + virtual_ok = await self._toggle_to_virtual() + steps["virtual_mode"] = {"ok": virtual_ok, "msg": "active" if virtual_ok else "failed to toggle"} + + # 5. Start watchdog (auto-detects CamoFox display and starts x11vnc) + watchdog_ok = await self._start_watchdog() + steps["watchdog"] = {"ok": watchdog_ok, "msg": "running" if watchdog_ok else "not started"} + steps["x11vnc"] = {"ok": self._is_x11vnc_running(), "msg": "running" if self._is_x11vnc_running() else "starting..."} + + # 6. Start openbox WM and maximize browser window to fill Xvfb display + if virtual_ok: + try: + # Get the display camoufox-bin is using + r = subprocess.run(["pgrep", "-f", "camoufox-bin"], capture_output=True, text=True) + pids = [p for p in r.stdout.strip().split("\n") if p.strip()] + disp = ":100" + for pid in pids: + try: + env_data = open(f"/proc/{pid}/environ", "rb").read().decode("utf-8", "replace") + for var in env_data.split("\x00"): + if var.startswith("DISPLAY="): + disp = var.split("=", 1)[1] + break + except Exception: + pass + # Force browser window to fill Xvfb display using xdotool + env_disp = os.environ.copy() + env_disp["DISPLAY"] = disp + vnc_w, vnc_h, _ = _get_vnc_resolution() + await asyncio.sleep(2) + wins = subprocess.run( + ["xdotool", "search", "--name", ""], + capture_output=True, text=True, env=env_disp + ).stdout.strip().split("\n") + for wid in wins: + if not wid.strip(): + continue + name = subprocess.run( + ["xdotool", "getwindowname", wid], + capture_output=True, text=True, env=env_disp + ).stdout.strip().lower() + if any(k in name for k in ["firefox", "camoufox", "mozilla"]): + subprocess.run(["xdotool", "windowmove", "--sync", wid, "0", "0"], + capture_output=True, env=env_disp) + subprocess.run(["xdotool", "windowsize", "--sync", wid, str(vnc_w), str(vnc_h)], + capture_output=True, env=env_disp) + steps["wm_maximize"] = {"ok": True, "msg": f"maximize on {disp} at {vnc_w}x{vnc_h}"} + except Exception as e: + steps["wm_maximize"] = {"ok": False, "msg": str(e)} + + # 7. Open default home tab using xdotool (API endpoint doesn't exist) + if virtual_ok: + cfg = get_config() + home_url = cfg.get("default_home_url", "https://www.google.com") + if home_url and home_url.strip(): + try: + # Find browser display + disp = ":100" + r = subprocess.run(["pgrep", "-f", "camoufox-bin"], capture_output=True, text=True) + for pid in r.stdout.strip().split("\n"): + if not pid.strip(): continue + try: + env_data = open(f"/proc/{pid}/environ", "rb").read().decode("utf-8", "replace") + for var in env_data.split("\x00"): + if var.startswith("DISPLAY="): + disp = var.split("=", 1)[1] + break + except Exception: + pass + env_disp = os.environ.copy() + env_disp["DISPLAY"] = disp + # Use xdotool to type URL in browser address bar + subprocess.run(["xdotool", "key", "ctrl+l"], capture_output=True, env=env_disp) + await asyncio.sleep(0.5) + subprocess.run(["xdotool", "type", "--clearmodifiers", home_url.strip()], capture_output=True, env=env_disp) + await asyncio.sleep(0.3) + subprocess.run(["xdotool", "key", "Return"], capture_output=True, env=env_disp) + steps["home_tab"] = {"ok": True, "msg": home_url.strip()} + except Exception as e: + steps["home_tab"] = {"ok": False, "msg": str(e)} + + all_ok = server_ok and virtual_ok + return { + "ok": all_ok, + "message": "CamoFox VNC stack started successfully." if all_ok else "Stack started with warnings — check steps.", + "vnc_url": "http://localhost:6080/vnc.html?autoconnect=true&resize=scale", + "steps": steps, + } + + async def _full_stop(self) -> dict: + """Stop all CamoFox+VNC stack components.""" + stopped = [] + + # Kill watchdog + r = await asyncio.create_subprocess_exec( + "pkill", "-f", "vnc-watchdog", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL + ) + await r.communicate() + stopped.append("watchdog") + + # Kill x11vnc + r = await asyncio.create_subprocess_exec( + "pkill", "-f", "x11vnc", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL + ) + await r.communicate() + stopped.append("x11vnc") + + # Kill camoufox-bin + r = await asyncio.create_subprocess_exec( + "pkill", "-f", "camoufox-bin", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL + ) + await r.communicate() + stopped.append("camoufox-bin") + + # Kill Node.js server + r = await asyncio.create_subprocess_exec( + "pkill", "-f", "node.*server.js", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL + ) + await r.communicate() + stopped.append("server") + + await asyncio.sleep(1) + return { + "ok": True, + "message": "CamoFox VNC stack stopped.", + "stopped": stopped, + } + + async def _stack_status(self) -> dict: + """Return status of all stack components.""" + cfg = get_config() + server_health = None + browser_connected = False + try: + client = CamofoxClient( + base_url=cfg["server_url"], + api_key=cfg.get("api_key", ""), + admin_key=cfg.get("admin_key", ""), + ) + server_health = await client.get("/health") + browser_connected = server_health.get("browserConnected", False) + await client.close() + except Exception: + pass + + return { + "ok": True, + "components": { + "xvfb": self._is_xvfb_running(), + "websockify": self._is_websockify_running(), + "server": server_health is not None, + "browser_connected": browser_connected, + "x11vnc": self._is_x11vnc_running(), + "watchdog": self._is_watchdog_running(), + }, + "server_health": server_health, + } diff --git a/default_config.yaml b/default_config.yaml index 0f569b9..3ea8fb6 100644 --- a/default_config.yaml +++ b/default_config.yaml @@ -2,6 +2,8 @@ server_url: "http://localhost:9377" api_key: "" admin_key: "" default_user_id: "" -default_headless: true +default_headless: "virtual" default_geo_preset: "" auto_start_server: true +default_home_url: "https://www.google.com" +vnc_resolution: "1280x800" diff --git a/scripts/user.js.template b/scripts/user.js.template new file mode 100644 index 0000000..1ae6226 --- /dev/null +++ b/scripts/user.js.template @@ -0,0 +1,23 @@ +// Force software rendering for ALL windows including popups +user_pref("gfx.webrender.all", false); +user_pref("gfx.webrender.software", true); +user_pref("gfx.webrender.enabled", false); +user_pref("gfx.webrender.force-disabled", true); +user_pref("layers.acceleration.disabled", true); +user_pref("layers.acceleration.force-enabled", false); +user_pref("layers.gpu-process.enabled", false); +user_pref("layers.offmainthreadcomposition.enabled", false); +user_pref("gfx.canvas.azure.backends", "cairo"); +user_pref("gfx.content.azure.backends", "cairo"); +user_pref("gfx.direct2d.disabled", true); +user_pref("media.rdd-process.enabled", false); +user_pref("security.sandbox.content.level", 0); +user_pref("dom.ipc.sandbox.content.level", 0); +// Force popups to open as tabs +user_pref("browser.link.open_newwindow", 3); +user_pref("browser.link.open_newwindow.restriction", 0); +// Disable Fission (site isolation) — fixes black cross-origin iframes and modals +user_pref("fission.autostart", false); +user_pref("fission.webContentIsolationStrategy", 0); +user_pref("gfx.webrender.compositor", false); +user_pref("gfx.webrender.compositor.force-enabled", false); diff --git a/scripts/vnc-watchdog.sh b/scripts/vnc-watchdog.sh new file mode 100755 index 0000000..edc5a66 --- /dev/null +++ b/scripts/vnc-watchdog.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# CamoFox VNC Watchdog +# Monitors camoufox-bin display and keeps x11vnc pointed at the correct one. +# Handles crashes, respawns, and display number changes. + +LAST_DISP="" + +echo "[watchdog] Started PID=$$" + +while true; do + # Detect current display used by camoufox-bin + DISP=$(for pid in $(pgrep -f 'camoufox-bin' 2>/dev/null); do + cat /proc/$pid/environ 2>/dev/null | tr '\0' '\n' | grep ^DISPLAY= + done | sort -u | head -1 | sed 's/DISPLAY=//') + + if [ -z "$DISP" ]; then + # camoufox-bin not running — kill stale x11vnc if any + if pgrep -f x11vnc > /dev/null 2>&1; then + echo "[watchdog] camoufox-bin gone, killing stale x11vnc" + pkill -f x11vnc 2>/dev/null + fi + LAST_DISP="" + sleep 3 + continue + fi + + X11VNC_RUNNING=$(pgrep -f x11vnc > /dev/null 2>&1 && echo yes || echo no) + X11VNC_DISP=$(pgrep -fa x11vnc 2>/dev/null | grep -o 'display :[0-9]*' | awk '{print $2}' | head -1) + + # Restart x11vnc if: not running, or watching wrong display + if [ "$X11VNC_RUNNING" = "no" ] || [ "$X11VNC_DISP" != "$DISP" ]; then + echo "[watchdog] $(date): x11vnc state=$X11VNC_RUNNING watching=$X11VNC_DISP target=$DISP — restarting" + pkill -f x11vnc 2>/dev/null + sleep 1 + nohup x11vnc -display $DISP -rfbport 5999 -nopw -forever -shared \ + -listen 127.0.0.1 -noxdamage >> /tmp/x11vnc.log 2>&1 & + echo "[watchdog] x11vnc started on $DISP (PID=$!)" + LAST_DISP="$DISP" + sleep 3 + fi + + sleep 3 +done diff --git a/tools/camofox_session.py b/tools/camofox_session.py index 1309279..b3fdb47 100644 --- a/tools/camofox_session.py +++ b/tools/camofox_session.py @@ -81,10 +81,18 @@ async def _dispatch(self, action: str, user_id: str) -> str: display_mode = "headless" elif mode == "virtual": display_mode = "virtual" + # Node.js server doesn't return vncUrl — construct it from config + if not vnc_url: + cfg = get_config() + server_url = cfg.get("server_url", "http://localhost:9377") + port = server_url.rstrip("/").split(":")[-1] + if not port.isdigit(): + port = "6080" + vnc_url = f"http://localhost:6080/vnc.html?autoconnect=true&resize=scale" else: display_mode = "headed" shared_state.set_vnc(user_id, vnc_url, display_mode) - if vnc_url: + if vnc_url and display_mode in ("virtual", "headed"): return ( f"Browser now visible via VNC. All previous tabs are invalidated — " f"create new tabs and snapshot before interacting.\nVNC URL: {vnc_url}" diff --git a/webui/camofox-viewer-panel.js b/webui/camofox-viewer-panel.js index f52e1a9..50e2a87 100644 --- a/webui/camofox-viewer-panel.js +++ b/webui/camofox-viewer-panel.js @@ -10,6 +10,8 @@ export function initPanel(panelEl) { let isDragging = false; let dragStartX = 0, dragStartY = 0, panelStartX = 0, panelStartY = 0; + let isMaximized = false; + let preMaxW, preMaxH, preMaxX, preMaxY; const store = Alpine.store("camofox"); @@ -34,9 +36,49 @@ export function initPanel(panelEl) { panelEl.style.right = "auto"; panelEl.style.bottom = "auto"; + // --- Maximize/Restore button --- + const controls = titleBar.querySelector(".camofox-controls"); + if (controls) { + const maxBtn = document.createElement("button"); + maxBtn.title = "Maximize / Restore"; + maxBtn.style.cssText = "background:none;border:none;cursor:pointer;color:inherit;font-size:14px;padding:0 4px;line-height:1;"; + maxBtn.textContent = "⛶"; + controls.insertBefore(maxBtn, controls.firstChild); + + maxBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (!isMaximized) { + // Save current state + preMaxW = panelEl.offsetWidth; + preMaxH = panelEl.offsetHeight; + preMaxX = parseFloat(panelEl.style.left) || 0; + preMaxY = parseFloat(panelEl.style.top) || 0; + // Maximize with small margin + const margin = 8; + panelEl.style.left = margin + "px"; + panelEl.style.top = margin + "px"; + panelEl.style.width = (window.innerWidth - margin * 2) + "px"; + panelEl.style.height = (window.innerHeight - margin * 2) + "px"; + maxBtn.textContent = "❐"; + maxBtn.title = "Restore"; + isMaximized = true; + } else { + // Restore + panelEl.style.left = preMaxX + "px"; + panelEl.style.top = preMaxY + "px"; + panelEl.style.width = preMaxW + "px"; + panelEl.style.height = preMaxH + "px"; + maxBtn.textContent = "⛶"; + maxBtn.title = "Maximize"; + isMaximized = false; + } + }); + } + // Drag: mousedown on title bar titleBar.addEventListener("mousedown", (e) => { if (e.target.closest(".camofox-controls")) return; + if (isMaximized) return; // don't drag when maximized isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY; @@ -65,22 +107,55 @@ export function initPanel(panelEl) { store.savePosition(rect.left, rect.top); }); - // Resize observer + // Resize observer — save size when user manually resizes const resizeObserver = new ResizeObserver(() => { - if (!isDragging) { + if (!isDragging && !isMaximized) { store.saveSize(panelEl.offsetWidth, panelEl.offsetHeight); } }); resizeObserver.observe(panelEl); - // Keep in viewport on window resize + // Proportional resize on window resize + // Track ratio of panel size to viewport at init time + let vpW = window.innerWidth; + let vpH = window.innerHeight; + window.addEventListener("resize", () => { + if (isMaximized) { + // Keep maximized panel filling the viewport + const margin = 8; + panelEl.style.left = margin + "px"; + panelEl.style.top = margin + "px"; + panelEl.style.width = (window.innerWidth - margin * 2) + "px"; + panelEl.style.height = (window.innerHeight - margin * 2) + "px"; + return; + } + + const curW = panelEl.offsetWidth; + const curH = panelEl.offsetHeight; + const newVpW = window.innerWidth; + const newVpH = window.innerHeight; + + // Scale panel proportionally with viewport change + const scaleX = newVpW / vpW; + const scaleY = newVpH / vpH; + const scale = Math.min(scaleX, scaleY); // uniform scale + const newW = Math.max(320, Math.round(curW * scale)); + const newH = Math.max(240, Math.round(curH * scale)); + panelEl.style.width = newW + "px"; + panelEl.style.height = newH + "px"; + store.saveSize(newW, newH); + + // Keep panel in viewport const rect = panelEl.getBoundingClientRect(); - if (rect.left > window.innerWidth - 100) { - panelEl.style.left = Math.max(0, window.innerWidth - panelEl.offsetWidth - 20) + "px"; + if (rect.left + newW > newVpW) { + panelEl.style.left = Math.max(0, newVpW - newW - 20) + "px"; } - if (rect.top > window.innerHeight - 40) { - panelEl.style.top = Math.max(0, window.innerHeight - panelEl.offsetHeight - 20) + "px"; + if (rect.top + newH > newVpH) { + panelEl.style.top = Math.max(0, newVpH - newH - 20) + "px"; } + + vpW = newVpW; + vpH = newVpH; }); } diff --git a/webui/config.html b/webui/config.html index 9c78907..94d7408 100644 --- a/webui/config.html +++ b/webui/config.html @@ -13,18 +13,26 @@ setting_up: false, generating: false, starting: false, + stopping: false, + serverRunning: false, + checkingStatus: false, installInfo: null, async checkStatus() { + this.checkingStatus = true; try { - const res = await fetchApi('/plugins/camofox_browser/camofox_setup', { + const res = await fetchApi('/plugins/camofox_browser/camofox_startup', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({action: 'full_status'}) + body: JSON.stringify({action: 'status'}) }); - this.installInfo = await res.json(); + const data = await res.json(); + this.installInfo = data; + this.serverRunning = data?.components?.server === true; } catch(e) { this.installInfo = {ok: false, error: e.message}; + this.serverRunning = false; } + this.checkingStatus = false; }, async generateKeys() { this.generating = true; @@ -222,15 +230,22 @@