223 lines
6.7 KiB
Python
223 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess # nosec
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from playwright.async_api import async_playwright, Error as PlaywrightError
|
|
|
|
__all__ = ["EnsureResult", "ensure_chromium_installed"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnsureResult:
|
|
browsers_path: Path
|
|
installed: bool
|
|
|
|
|
|
def _user_cache_dir() -> Path:
|
|
"""
|
|
Cross-platform cache dir without extra deps.
|
|
Linux: $XDG_CACHE_HOME or ~/.cache
|
|
macOS: ~/Library/Caches
|
|
Windows: %LOCALAPPDATA%
|
|
"""
|
|
if os.name == "nt":
|
|
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
return Path(base)
|
|
|
|
if sys.platform == "darwin":
|
|
return Path.home() / "Library" / "Caches"
|
|
|
|
return Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
|
|
|
|
|
|
def _default_browsers_path() -> Path:
|
|
"""
|
|
If PLAYWRIGHT_BROWSERS_PATH is set, honor it (Playwright-standard).
|
|
Otherwise use a user-writable cache path (safe for AppImage/pip installs).
|
|
"""
|
|
env = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
|
|
if env and env.strip() and env.strip() != "0":
|
|
return Path(env).expanduser()
|
|
|
|
return _user_cache_dir() / "cspresso" / "pw-browsers"
|
|
|
|
|
|
def _looks_like_python(path: str) -> bool:
|
|
p = Path(path)
|
|
name = p.name.lower()
|
|
return (
|
|
p.exists()
|
|
and os.access(str(p), os.X_OK)
|
|
and (
|
|
name == "python" or name.startswith("python3") or name.startswith("python")
|
|
)
|
|
)
|
|
|
|
|
|
def _find_python_executable() -> str:
|
|
"""
|
|
In AppImage bundles, sys.executable may be the AppImage itself.
|
|
We need the embedded python binary so we can run: python -m playwright install chromium
|
|
"""
|
|
# 1) Normal venv/system case
|
|
if _looks_like_python(sys.executable):
|
|
return sys.executable
|
|
|
|
# 2) Sometimes present
|
|
base = getattr(sys, "_base_executable", None)
|
|
if base and _looks_like_python(base):
|
|
return base
|
|
|
|
# 3) Embedded python typically lives under sys.prefix/bin
|
|
bindir = "Scripts" if os.name == "nt" else "bin"
|
|
candidates = [
|
|
Path(sys.prefix)
|
|
/ bindir
|
|
/ f"python{sys.version_info.major}.{sys.version_info.minor}",
|
|
Path(sys.prefix) / bindir / f"python{sys.version_info.major}",
|
|
Path(sys.prefix) / bindir / "python3",
|
|
Path(sys.prefix) / bindir / "python",
|
|
Path(sys.base_prefix)
|
|
/ bindir
|
|
/ f"python{sys.version_info.major}.{sys.version_info.minor}",
|
|
Path(sys.base_prefix) / bindir / f"python{sys.version_info.major}",
|
|
Path(sys.base_prefix) / bindir / "python3",
|
|
Path(sys.base_prefix) / bindir / "python",
|
|
]
|
|
for c in candidates:
|
|
if _looks_like_python(str(c)):
|
|
return str(c)
|
|
|
|
# 4) Last resort: host python on PATH
|
|
for name in (
|
|
f"python{sys.version_info.major}.{sys.version_info.minor}",
|
|
"python3",
|
|
"python",
|
|
):
|
|
p = shutil.which(name)
|
|
if p and _looks_like_python(p):
|
|
return p
|
|
|
|
# Fallback (won't fix AppImage, but avoids crashing)
|
|
return sys.executable
|
|
|
|
|
|
def _env_with_browsers_path(browsers_path: Path) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path)
|
|
return env
|
|
|
|
|
|
def _is_writable_dir(path: Path) -> bool:
|
|
try:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
probe = path / ".write_probe"
|
|
probe.write_text("x", encoding="utf-8")
|
|
probe.unlink(missing_ok=True)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _acquire_install_lock(
|
|
lock_path: Path, timeout_s: float = 120.0, poll_s: float = 0.2
|
|
) -> None:
|
|
start = time.time()
|
|
while True:
|
|
try:
|
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
os.close(fd)
|
|
return
|
|
except FileExistsError:
|
|
if time.time() - start > timeout_s:
|
|
raise TimeoutError(f"Timed out waiting for install lock: {lock_path}")
|
|
time.sleep(poll_s)
|
|
|
|
|
|
def _release_install_lock(lock_path: Path) -> None:
|
|
try:
|
|
lock_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass # nosec
|
|
|
|
|
|
def _install_chromium(browsers_path: Path, with_deps: bool = False) -> None:
|
|
env = _env_with_browsers_path(browsers_path)
|
|
py = _find_python_executable()
|
|
|
|
cmd = [py, "-m", "playwright", "install"]
|
|
if with_deps:
|
|
cmd.append("--with-deps")
|
|
cmd.append("chromium")
|
|
|
|
subprocess.run(cmd, check=True, env=env) # nosec
|
|
|
|
|
|
async def _can_launch_chromium(browsers_path: Path) -> bool:
|
|
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path)
|
|
try:
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(headless=True)
|
|
await browser.close()
|
|
return True
|
|
except PlaywrightError:
|
|
return False
|
|
|
|
|
|
async def ensure_chromium_installed(
|
|
browsers_path: Path | None = None,
|
|
*,
|
|
with_deps: bool = False,
|
|
lock_timeout_s: float = 120.0,
|
|
) -> EnsureResult:
|
|
"""
|
|
Ensure Playwright Chromium is installed and launchable.
|
|
|
|
- Honors PLAYWRIGHT_BROWSERS_PATH if set.
|
|
- Defaults to a user cache dir (safe for AppImage readonly mounts).
|
|
- Uses embedded python to run playwright installer when sys.executable is the AppImage.
|
|
"""
|
|
explicit = browsers_path is not None
|
|
bp = browsers_path or _default_browsers_path()
|
|
|
|
# If it already works, do nothing.
|
|
if await _can_launch_chromium(bp):
|
|
return EnsureResult(browsers_path=bp, installed=False)
|
|
|
|
# If we need to install and the chosen dir isn't writable, fall back (unless explicit).
|
|
if not explicit and not _is_writable_dir(bp):
|
|
bp = _user_cache_dir() / "cspresso" / "pw-browsers"
|
|
if not _is_writable_dir(bp):
|
|
bp = Path(tempfile.gettempdir()) / "cspresso" / "pw-browsers"
|
|
bp.mkdir(parents=True, exist_ok=True)
|
|
|
|
if explicit and not _is_writable_dir(bp):
|
|
raise OSError(
|
|
f"Browsers path is not writable: {bp}\n"
|
|
"Choose a writable directory via --browsers-path or set PLAYWRIGHT_BROWSERS_PATH."
|
|
)
|
|
|
|
lock_path = bp / ".install.lock"
|
|
_acquire_install_lock(lock_path, timeout_s=lock_timeout_s)
|
|
try:
|
|
if await _can_launch_chromium(bp):
|
|
return EnsureResult(browsers_path=bp, installed=False)
|
|
|
|
_install_chromium(bp, with_deps=with_deps)
|
|
|
|
if not await _can_launch_chromium(bp):
|
|
raise RuntimeError(
|
|
"Chromium install completed, but Chromium still failed to launch. "
|
|
"On Linux, you may need additional system dependencies."
|
|
)
|
|
|
|
return EnsureResult(browsers_path=bp, installed=True)
|
|
finally:
|
|
_release_install_lock(lock_path)
|