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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ CLAUDE.md
publish-docker.sh
.beads
debug
backend/.data/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ pip install -r requirements.txt
uvicorn main:app --reload --port 8080
```

By default, Docker stores profile data in `/data`. For local development, if `/data` is not writable, the backend falls back to `backend/.data`. You can override this with `CLOAKBROWSER_MANAGER_DATA_DIR=/path/to/data`.

### Frontend

```bash
Expand Down
10 changes: 8 additions & 2 deletions backend/browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

from cloakbrowser import launch_persistent_context_async

from .vnc_manager import VNCManager
if __package__:
from .vnc_manager import VNCManager
else: # Support importing browser_manager as a top-level module.
from vnc_manager import VNCManager

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

Expand Down Expand Up @@ -341,7 +344,10 @@ async def cleanup_stale(self):

async def auto_launch_all(self):
"""Launch all profiles with auto_launch=True. Called on startup."""
from . import database as db
if __package__:
from . import database as db
else:
import database as db

profiles = db.list_profiles()
auto_profiles = [p for p in profiles if p.get("auto_launch")]
Expand Down
32 changes: 31 additions & 1 deletion backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,44 @@

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

DATA_DIR = Path("/data")
ENV_DATA_DIR = "CLOAKBROWSER_MANAGER_DATA_DIR"
DOCKER_DATA_DIR = Path("/data")
LOCAL_DATA_DIR = Path(__file__).resolve().parent / ".data"


def _is_usable_data_dir(path: Path) -> bool:
"""Return True if path can be created and written to."""
probe = path / f".write-test-{os.getpid()}"
try:
path.mkdir(parents=True, exist_ok=True)
probe.write_text("ok")
probe.unlink(missing_ok=True)
return True
except OSError:
return False


def _resolve_data_dir() -> Path:
"""Resolve the profile data directory for Docker and local development."""
configured = os.environ.get(ENV_DATA_DIR)
if configured:
return Path(configured).expanduser()

if _is_usable_data_dir(DOCKER_DATA_DIR):
return DOCKER_DATA_DIR

return LOCAL_DATA_DIR


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


Expand Down
41 changes: 28 additions & 13 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,34 @@
import starlette.requests
from starlette.types import ASGIApp, Receive, Scope, Send

from . import database as db
from .browser_manager import BrowserManager
from .models import (
ClipboardRequest,
LaunchResponse,
LoginRequest,
ProfileCreate,
ProfileResponse,
ProfileStatusResponse,
ProfileUpdate,
StatusResponse,
TagResponse,
)
if __package__:
from . import database as db
from .browser_manager import BrowserManager
from .models import (
ClipboardRequest,
LaunchResponse,
LoginRequest,
ProfileCreate,
ProfileResponse,
ProfileStatusResponse,
ProfileUpdate,
StatusResponse,
TagResponse,
)
else: # Support `uvicorn main:app` from the backend directory.
import database as db
from browser_manager import BrowserManager
from models import (
ClipboardRequest,
LaunchResponse,
LoginRequest,
ProfileCreate,
ProfileResponse,
ProfileStatusResponse,
ProfileUpdate,
StatusResponse,
TagResponse,
)

logger = logging.getLogger("cloakbrowser.manager")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
Expand Down
8 changes: 7 additions & 1 deletion backend/tests/test_browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,13 @@ def test_allocate_cdp_port_all_occupied_raises():
for i in range(CDP_PORT_RANGE):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", BASE_CDP_PORT + i))
try:
s.bind(("127.0.0.1", BASE_CDP_PORT + i))
except OSError:
# A real local service may already occupy a port in the range;
# that still counts as unavailable to BrowserManager.
s.close()
continue
s.listen(1)
blockers.append(s)
with pytest.raises(ValueError, match="No free CDP ports"):
Expand Down
16 changes: 16 additions & 0 deletions backend/tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@
from backend import database as db


# ── data directory resolution ────────────────────────────────────────────────


def test_resolve_data_dir_uses_env_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(db.ENV_DATA_DIR, str(tmp_path))

assert db._resolve_data_dir() == tmp_path


def test_resolve_data_dir_falls_back_to_local(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(db.ENV_DATA_DIR, raising=False)
monkeypatch.setattr(db, "_is_usable_data_dir", lambda _path: False)

assert db._resolve_data_dir() == db.LOCAL_DATA_DIR


# ── init_db ──────────────────────────────────────────────────────────────────


Expand Down