Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 24 additions & 20 deletions backend/browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import os
import socket
import sys
import time
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -174,14 +175,9 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile:
self._launching.add(profile_id)

display, ws_port = await self.vnc.allocate()
vnc_available = self.vnc.available

try:
cdp_port = self._allocate_cdp_port()
except ValueError:
async with self._lock:
self._launching.discard(profile_id)
await self.vnc.stop_vnc(display)
raise
cdp_port = self._allocate_cdp_port()

# Clean stale Chromium lock files (left by previous container crashes)
user_data_dir = Path(profile["user_data_dir"])
Expand All @@ -193,27 +189,32 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile:
_init_profile_defaults(user_data_dir)

try:
# Start KasmVNC on the allocated display
await self.vnc.start_vnc(
display,
ws_port,
width=profile.get("screen_width", 1920),
height=profile.get("screen_height", 1080),
)
# Start KasmVNC on the allocated display (if available)
if vnc_available:
await self.vnc.start_vnc(
display,
ws_port,
width=profile.get("screen_width", 1920),
height=profile.get("screen_height", 1080),
)

# Build fingerprint args from profile settings
extra_args = self._build_fingerprint_args(profile)
extra_args += profile.get("launch_args") or []
extra_args.append(f"--remote-debugging-port={cdp_port}")

# Normalize proxy format (host:port:user:pass → http://user:pass@host:port)
# Normalize proxy format
raw_proxy = profile.get("proxy") or None
proxy = _normalize_proxy(raw_proxy) if raw_proxy else None
if proxy:
_validate_proxy(proxy)

# Launch CloakBrowser on that display
# DISPLAY is passed via env kwarg to avoid process-wide os.environ mutation
# Build env: on VNC, set DISPLAY; on native desktop, don't
launch_env = {**os.environ}
if vnc_available:
launch_env["DISPLAY"] = f":{display}"

# Launch CloakBrowser
context = await launch_persistent_context_async(
user_data_dir=profile["user_data_dir"],
headless=bool(profile.get("headless", False)),
Expand All @@ -230,7 +231,7 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile:
"width": profile.get("screen_width", 1920),
"height": profile.get("screen_height", 1080) - 133,
},
env={**os.environ, "DISPLAY": f":{display}"},
env=launch_env,
)

# Inject clipboard listener: captures copied text on every page
Expand Down Expand Up @@ -380,9 +381,12 @@ def _build_fingerprint_args(self, profile: dict[str, Any]) -> list[str]:
"""Build extra Chromium args from profile fingerprint settings."""
args: list[str] = [
"--disable-infobars",
"--test-type", # suppress "unsupported flag: --no-sandbox" bad flags warning
"--use-angle=swiftshader", # software GL for VNC (no GPU in container)
"--test-type",
]
# Only use software GL when running via VNC (no GPU in container).
# On native desktop (Windows/macOS), hardware GL is available.
if self.vnc.available:
args.append("--use-angle=swiftshader")

seed = profile.get("fingerprint_seed")
if seed is not None:
Expand Down
16 changes: 15 additions & 1 deletion backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,28 @@

import datetime
import json
import os
import random
import sqlite3
import sys
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any

DATA_DIR = Path("/data")


def _get_data_dir() -> Path:
"""Platform-appropriate data directory."""
if os.environ.get("CLOAKBROWSER_DATA_DIR"):
return Path(os.environ["CLOAKBROWSER_DATA_DIR"])
if sys.platform == "win32":
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "cloakbrowser-manager"
return Path("/data")


DATA_DIR = _get_data_dir()
DB_PATH = DATA_DIR / "profiles.db"


Expand Down
30 changes: 25 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,11 +589,20 @@ async def get_system_status():

@app.post("/api/profiles/{profile_id}/clipboard")
async def set_clipboard(profile_id: str, body: ClipboardRequest):
"""Push text into the VNC session's X clipboard via xclip."""
"""Push text into the VNC session's X clipboard via xclip.

On Windows / native desktop, clipboard push is not supported
(xclip is Linux-only). Returns success anyway so the frontend
doesn't error.
"""
running = browser_mgr.running.get(profile_id)
if not running:
raise HTTPException(status_code=404, detail="Profile not running")

if not browser_mgr.vnc.available or running.display == 0:
# Native desktop mode — clipboard is handled by the OS directly
return {"ok": True, "native": True}

import os

# Kill previous xclip for this display (it stays alive to serve paste)
Expand Down Expand Up @@ -647,7 +656,10 @@ async def get_clipboard(profile_id: str):
except Exception as exc:
logger.debug("Playwright clipboard read failed: %s", exc)

# Fallback: xclip for non-Chrome clipboard owners
# Fallback: xclip for non-Chrome clipboard owners (Linux/VNC only)
if not browser_mgr.vnc.available or running.display == 0:
return {"text": ""}

import os

env = {**os.environ, "DISPLAY": f":{running.display}"}
Expand Down Expand Up @@ -676,7 +688,11 @@ async def get_clipboard(profile_id: str):

@app.websocket("/api/profiles/{profile_id}/vnc")
async def vnc_proxy(websocket: WebSocket, profile_id: str):
"""Proxy WebSocket frames between the frontend and a profile's KasmVNC."""
"""Proxy WebSocket frames between the frontend and a profile's KasmVNC.

When VNC is unavailable (native desktop mode), reject the connection
so the frontend can show a "browser on desktop" view.
"""
if not await _check_websocket_origin(websocket):
return

Expand All @@ -685,6 +701,10 @@ async def vnc_proxy(websocket: WebSocket, profile_id: str):
await websocket.close(code=4004, reason="Profile not running")
return

if not browser_mgr.vnc.available or running.ws_port == 0:
await websocket.close(code=4005, reason="VNC unavailable — browser running on desktop")
return

# Accept with client's requested subprotocol (if any) — RFC 6455 requires
# the server must not respond with a subprotocol the client didn't request.
requested = websocket.scope.get("subprotocols", [])
Expand Down Expand Up @@ -816,8 +836,8 @@ async def vnc_to_client():
)

# Dump Xvnc log on disconnect
import os
xvnc_log = f"/tmp/xvnc-{running.display}.log"
import tempfile
xvnc_log = os.path.join(tempfile.gettempdir(), f"xvnc-{running.display}.log")
if os.path.exists(xvnc_log):
with open(xvnc_log) as f:
log_content = f.read()
Expand Down
68 changes: 59 additions & 9 deletions backend/vnc_manager.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""KasmVNC display allocation and lifecycle management."""
"""KasmVNC display allocation and lifecycle management.

On platforms without KasmVNC (Windows, macOS without XQuartz), VNC is
unavailable — browsers open natively in their own window instead.
"""

from __future__ import annotations

import asyncio
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass, field
import sys
from dataclasses import dataclass

logger = logging.getLogger("cloakbrowser.manager.vnc")

Expand All @@ -18,16 +24,36 @@ class VNCInstance:
process: subprocess.Popen | None = None


def _xvnc_available() -> bool:
"""Check if KasmVNC (Xvnc) is installed and runnable."""
return shutil.which("Xvnc") is not None


class VNCManager:
BASE_DISPLAY = 100
BASE_WS_PORT = 6100

def __init__(self):
self._allocated: dict[int, VNCInstance] = {}
self._lock = asyncio.Lock()
self._available: bool | None = None # cached after first check

@property
def available(self) -> bool:
"""Whether KasmVNC is available on this system."""
if self._available is None:
self._available = _xvnc_available()
return self._available

async def allocate(self) -> tuple[int, int]:
"""Returns (display_number, ws_port) for a new profile."""
"""Returns (display_number, ws_port) for a new profile.

When VNC is unavailable, returns (0, 0) — the caller should
skip VNC setup and let the browser open natively.
"""
if not self.available:
return 0, 0

async with self._lock:
display = self.BASE_DISPLAY
while display in self._allocated:
Expand All @@ -42,12 +68,18 @@ async def start_vnc(
ws_port: int,
width: int = 1920,
height: int = 1080,
) -> subprocess.Popen:
"""Start Xvnc (KasmVNC) on the given display."""
) -> subprocess.Popen | None:
"""Start Xvnc (KasmVNC) on the given display.

Returns None when VNC is unavailable (caller should skip VNC).
"""
if not self.available or display == 0:
logger.info("VNC unavailable — browser will open natively on desktop")
return None

xvnc_bin = shutil.which("Xvnc") or "Xvnc"

# KasmVNC requires -httpd to enable the WebSocket handler on the websocket port.
# Without it, the port accepts TCP but won't do WebSocket upgrade.
httpd_dir = "/usr/share/kasmvnc/www"

cmd = [
Expand All @@ -59,12 +91,13 @@ async def start_vnc(
"-depth", "24",
"-SecurityTypes", "None",
"-DisableBasicAuth",
"-interface", "127.0.0.1", # internal only, proxied by FastAPI
"-interface", "127.0.0.1",
"-AlwaysShared",
"-httpd", httpd_dir,
]

log_path = f"/tmp/xvnc-{display}.log"
import tempfile
log_path = os.path.join(tempfile.gettempdir(), f"xvnc-{display}.log")
logger.info("Starting Xvnc on :%d (ws_port=%d) log=%s", display, ws_port, log_path)

log_file = open(log_path, "w")
Expand All @@ -73,7 +106,7 @@ async def start_vnc(
stdout=log_file,
stderr=log_file,
)
log_file.close() # Popen inherited the fd, parent doesn't need it
log_file.close()

# Wait a moment for Xvnc to initialize
await asyncio.sleep(0.5)
Expand All @@ -95,6 +128,9 @@ async def start_vnc(

async def stop_vnc(self, display: int):
"""Kill Xvnc for given display and release allocation."""
if display == 0:
return

async with self._lock:
instance = self._allocated.pop(display, None)

Expand All @@ -118,6 +154,20 @@ async def cleanup_all(self):

async def cleanup_stale(self):
"""Kill orphan Xvnc processes from previous runs."""
if not self.available:
return

if sys.platform == "win32":
# On Windows, use taskkill to clean up leftover Xvnc if somehow present
try:
subprocess.run(
["taskkill", "/F", "/IM", "Xvnc.exe"],
capture_output=True,
)
except FileNotFoundError:
pass
return

try:
result = subprocess.run(
["pkill", "-f", r"Xvnc :[0-9]"],
Expand Down
Loading