diff --git a/.gitignore b/.gitignore index d5db79e..578aa67 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ CLAUDE.md publish-docker.sh .beads debug +backend/.data/ diff --git a/README.md b/README.md index e413c55..7300cf9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/browser_manager.py b/backend/browser_manager.py index 327f91b..f79c9a3 100644 --- a/backend/browser_manager.py +++ b/backend/browser_manager.py @@ -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") @@ -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")] diff --git a/backend/database.py b/backend/database.py index e468256..5b79f7b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -4,6 +4,7 @@ import datetime import json +import os import random import sqlite3 import uuid @@ -11,7 +12,36 @@ 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" diff --git a/backend/main.py b/backend/main.py index 92727a5..9d5179a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/tests/test_browser_manager.py b/backend/tests/test_browser_manager.py index ba4665e..fe8dfce 100644 --- a/backend/tests/test_browser_manager.py +++ b/backend/tests/test_browser_manager.py @@ -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"): diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index ef292c5..aad7e3e 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -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 ──────────────────────────────────────────────────────────────────