from __future__ import annotations import hashlib import shutil from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from .auth import EditorAuthManager, create_editor_auth_router from .manager import UIBuilderManager from .router import DataProvider, create_ui_builder_router def _seed_config(source: Path, destination: Path) -> None: if destination.exists(): return if source.exists(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) def install_ui_builder( app: FastAPI, *, build_version: str = "", settings_dir: Path, data_providers: dict[str, DataProvider], source_labels: dict[str, str] | None = None, api_prefix: str = "/api/ui-builder", assets_url: str = "/ui-builder-assets", editor_url: str = "/editor", runtime_url: str = "/", settings_filename: str = "ui_builder.json", draft_filename: str = "ui_builder_draft.json", published_filename: str = "ui_builder_published.json", custom_script_urls: list[str] | None = None, custom_style_urls: list[str] | None = None, ) -> UIBuilderManager: """Install published runtime and PIN-protected editor.""" settings_dir = Path(settings_dir) legacy = settings_dir / Path(settings_filename).name draft_file = settings_dir / Path(draft_filename).name published_file = settings_dir / Path(published_filename).name _seed_config(legacy, draft_file) _seed_config(legacy, published_file) draft_manager = UIBuilderManager(settings_dir, filename=draft_file.name) published_manager = UIBuilderManager(settings_dir, filename=published_file.name) auth = EditorAuthManager(settings_dir) assets_dir = Path(__file__).resolve().parent / "static" app.mount( assets_url, StaticFiles(directory=assets_dir), name="ui-builder-assets", ) editor_api = f"{api_prefix.rstrip('/')}/editor" runtime_api = f"{api_prefix.rstrip('/')}/runtime" auth_api = f"{api_prefix.rstrip('/')}/auth" app.include_router( create_editor_auth_router( auth, prefix=auth_api, editor_url=editor_url, ) ) app.include_router( create_ui_builder_router( draft_manager, data_providers, source_labels=source_labels, prefix=editor_api, access_dependency=auth.require_editor, publish_manager=published_manager, ) ) app.include_router( create_ui_builder_router( published_manager, data_providers, source_labels=source_labels, prefix=runtime_api, read_only=True, ) ) editor_template = (assets_dir / "index.html").read_text(encoding="utf-8") runtime_template = (assets_dir / "runtime.html").read_text(encoding="utf-8") asset_digest = hashlib.sha256() for asset_name in ("app.js", "styles.css"): asset_path = assets_dir / asset_name if asset_path.exists(): asset_digest.update(asset_path.read_bytes()) asset_version = asset_digest.hexdigest()[:12] custom_script_urls = custom_script_urls or [] custom_style_urls = custom_style_urls or [] def render_page(mode: str) -> HTMLResponse: page_api = editor_api if mode == "editor" else runtime_api # Runtime has its own HTML entry point and contains no Builder DOM at all. # This prevents the visual editor from flashing during normal page load. template = editor_template if mode == "editor" else runtime_template html = ( template.replace("__UI_BUILDER_ASSETS__", assets_url.rstrip("/")) .replace("__UI_BUILDER_ASSET_VERSION__", asset_version) .replace("__UI_BUILDER_BUILD_VERSION__", str(build_version or asset_version)) .replace("__UI_BUILDER_API__", page_api) .replace("__UI_BUILDER_AUTH_API__", auth_api) .replace("__UI_BUILDER_EDITOR__", editor_url) .replace("__UI_BUILDER_RUNTIME__", runtime_url) .replace("__UI_BUILDER_MODE__", mode) .replace("__UI_BUILDER_EDITOR_HOTKEY__", "Ctrl+Shift+E") .replace( "__UI_BUILDER_CUSTOM_STYLES__", "\n".join( f'' for url in custom_style_urls ), ) .replace( "__UI_BUILDER_CUSTOM_SCRIPTS__", "\n".join( f'' for url in custom_script_urls ), ) ) return HTMLResponse(html) @app.get(editor_url, include_in_schema=False) async def ui_builder_editor(request: Request): # Runtime is the default landing mode, even if an old bookmark points # directly at /editor. The visual editor is opened only explicitly # from the runtime toolbar and carries ?open=1 after PIN auth. if request.query_params.get("open") != "1": return RedirectResponse(runtime_url, status_code=302) if not auth.is_request_authenticated(request): return RedirectResponse(runtime_url, status_code=302) return render_page("editor") @app.get(runtime_url, include_in_schema=False) async def ui_builder_runtime() -> HTMLResponse: return render_page("runtime") app.state.ui_builder_manager = draft_manager app.state.ui_builder_draft_manager = draft_manager app.state.ui_builder_published_manager = published_manager app.state.ui_builder_auth = auth return draft_manager