import os import socket import subprocess import sys import time from pathlib import Path import requests class AppConfig: def __init__(self): self.project_root = Path(__file__).resolve().parents[2] self.runtime_mode = self._normalize_runtime_mode(os.getenv("OPENCLAW_RUNTIME_MODE") or os.getenv("OPENCLAW_BACKEND_MODE")) self.venv_root = self.project_root / ".venv" self.venv_python = self.venv_root / "Scripts" / "python.exe" self.venv_pythonw = self.venv_root / "Scripts" / "pythonw.exe" self.host = os.getenv("APP_HOST", "127.0.0.1") self.port = int(os.getenv("APP_PORT", "5000")) def _normalize_runtime_mode(self, value): mode = (value or "").strip().lower() if mode in {"release", "prod", "production", "bundle", "packaged"}: return "release" return "dev" @property def base_url(self): return f"http://{self.host}:{self.port}" @property def python_executable(self): return Path(sys.executable).resolve() @property def python_prefix(self): return Path(sys.prefix).resolve() @property def is_release_mode(self): return self.runtime_mode == "release" @property def backend_entry(self): return self.project_root / "backend_main.py" @property def is_running_in_project_venv(self): if self.is_release_mode: return False try: if self.python_prefix == self.venv_root.resolve(): return True allowed_targets = { self.venv_python.resolve(), self.venv_pythonw.resolve(), } return self.python_executable in allowed_targets except Exception: return False class BackendRuntime: def __init__(self, config=None): self.config = config or AppConfig() @property def host(self): return self.config.host @property def port(self): return self.config.port @property def base_url(self): return self.config.base_url @property def backend_entry(self): return self.config.backend_entry def is_backend_alive(self, host=None, port=None, timeout=0.4): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: return sock.connect_ex((host or self.host, port or self.port)) == 0 finally: sock.close() def launch_backend_process(self): if self.config.is_release_mode: raise RuntimeError("当前运行模式为发布态,不能从 Python 壳层再启动 backend_main.py") backend_python = self.config.venv_python if not backend_python.exists(): raise RuntimeError(f"未找到后端解释器: {backend_python}") backend_entry = self.backend_entry if not backend_entry.exists(): raise RuntimeError(f"未找到后端入口: {backend_entry}") env = os.environ.copy() env["OPENCLAW_PROJECT_VENV_REEXEC"] = "1" env.setdefault("OPENCLAW_BACKEND_MODE", "dev") env.setdefault("OPENCLAW_RUNTIME_MODE", "dev") creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) return subprocess.Popen( [str(backend_python), str(backend_entry)], cwd=str(self.config.project_root), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=creationflags, ) def terminate_backend_process(self, process, timeout=3): if process is None or process.poll() is not None: return try: process.terminate() process.wait(timeout=timeout) except Exception: if process.poll() is None: process.kill() class FrontendRuntime: def __init__(self, config=None, backend_runtime=None): self.config = config or AppConfig() self.backend_runtime = backend_runtime or BackendRuntime(self.config) self.frontend_dev_url = os.getenv("FRONTEND_DEV_URL", "http://127.0.0.1:5173").strip().rstrip("/") self.frontend_dist_index = self.config.project_root / "frontend" / "dist" / "index.html" self.backend_wait_timeout_ms = 15000 self.backend_wait_interval_ms = 200 def resolve_frontend_entry_url(self, backend_url): if self.frontend_dev_url: try: requests.get(self.frontend_dev_url, timeout=1.2) return self.frontend_dev_url except Exception: pass if self.frontend_dist_index.exists(): return backend_url.rstrip("/") + "/index.html" return backend_url.rstrip("/") + "/admin.html" def wait_for_backend(self, process=None, timeout_ms=None, interval_ms=None): timeout_ms = timeout_ms or self.backend_wait_timeout_ms interval_ms = interval_ms or self.backend_wait_interval_ms started_at = time.time() * 1000 while True: if self.backend_runtime.is_backend_alive(): return True, "" if process is not None and process.poll() is not None: return False, f"独立后台进程已退出,退出码: {process.returncode}" elapsed = time.time() * 1000 - started_at if elapsed >= timeout_ms: return False, f"后台启动超时,{int(elapsed)}ms 内未监听 {self.backend_runtime.host}:{self.backend_runtime.port}" time.sleep(interval_ms / 1000.0)