#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ WL Pipeline Client — Unity Pipeline 서버(HTTP + JSON)를 직접 호출하는 라이브러리 · CLI · 브라우저 제어판 프록시. Unity CLI(`unity command …`)와 같은 백엔드(com.unity.pipeline · 127.0.0.1:78xx 에디터 / 79xx 런타임)를 표준 라이브러리만으로 호출한다. "사용자 지정 인터페이스"를 만들 때 이 파일을 import 하거나, `serve` 모드로 control_panel.html 을 띄운다 (프록시가 토큰을 주입하므로 브라우저 쪽 CORS·토큰 처리가 없다). CLI 예시 (Unity 프로젝트 루트에서): python Tools/Pipeline/pipeline_client.py status # GET /api/status python Tools/Pipeline/pipeline_client.py commands wl_ # 명령 검색 (compact) python Tools/Pipeline/pipeline_client.py exec editor_status # POST /api/exec {"commandLine": …} python Tools/Pipeline/pipeline_client.py exec "wl_timescale --scale 0.5" python Tools/Pipeline/pipeline_client.py exec --runtime runtime_status # 런타임 서버(.unity-pipeline-runtime-port) python Tools/Pipeline/pipeline_client.py serve # http://127.0.0.1:7700 제어판 환경 변수: PIPELINE_PROJECT Unity 프로젝트 루트 (기본: 이 파일 기준 ../../) PIPELINE_RUNTIME_DIR 런타임 포트 파일이 있는 폴더 (기본: 프로젝트 루트 = 에디터 Play 모드 · 빌드는 exe 옆) """ import argparse import http.client import http.server import json import os import sys import urllib.error import urllib.parse import urllib.request import webbrowser from pathlib import Path ROOT = Path(os.environ.get("PIPELINE_PROJECT") or Path(__file__).resolve().parents[2]) EDITOR_DESCRIPTOR = ROOT / "Library" / "Pipeline" / ".unity-pipeline-port" RUNTIME_DESCRIPTOR_NAME = ".unity-pipeline-runtime-port" PANEL_HTML = Path(__file__).resolve().parent / "control_panel.html" DEFAULT_PANEL_PORT = 7700 # Pipeline 포트 범위(7800~7999) 바깥 class PipelineError(RuntimeError): pass def descriptor_path(mode="editor", runtime_dir=None): if mode == "editor": return EDITOR_DESCRIPTOR base = Path(runtime_dir or os.environ.get("PIPELINE_RUNTIME_DIR") or ROOT) return base / RUNTIME_DESCRIPTOR_NAME def read_descriptor(mode="editor", runtime_dir=None): """포트 파일(JSON)을 읽는다: port · evalToken · projectPath … 서버가 없으면 PipelineError.""" path = descriptor_path(mode, runtime_dir) if not path.exists(): if mode == "editor": hint = "에디터가 켜져 있고 Pipeline 서버가 기동됐는지 (unity status)" else: hint = ("Play 모드/개발 빌드가 실행 중이고 Project Settings > Pipeline > Runtime > " "Enable In Builds 가 켜져 있는지") raise PipelineError("%s 포트 파일이 없습니다: %s - %s 확인" % (mode, path, hint)) return json.loads(path.read_text(encoding="utf-8")) class PipelineClient: """Unity Pipeline HTTP API 클라이언트. refresh() 가 포트 파일을 다시 읽는다 (리로드 후 토큰 교체 대비).""" def __init__(self, mode="editor", runtime_dir=None, timeout=60): self.mode = mode self.runtime_dir = runtime_dir self.timeout = timeout self.descriptor = None self.refresh() def refresh(self): self.descriptor = read_descriptor(self.mode, self.runtime_dir) self.port = self.descriptor["port"] self.token = self.descriptor["evalToken"] self.base = "http://127.0.0.1:%d" % self.port return self.descriptor # -- 저수준 ----------------------------------------------------------------- def request_raw(self, method, path, body=None): """(status_code, bytes, content_type). body 는 dict/list(JSON 직렬화) 또는 bytes.""" data = None if body is not None: data = body if isinstance(body, (bytes, bytearray)) else json.dumps(body).encode("utf-8") req = urllib.request.Request(self.base + path, data=data, method=method) req.add_header("Authorization", "Bearer " + self.token) if data is not None: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=self.timeout) as r: return r.status, r.read(), r.headers.get("Content-Type", "application/json") except urllib.error.HTTPError as e: return e.code, e.read(), e.headers.get("Content-Type", "application/json") except (urllib.error.URLError, http.client.HTTPException, ConnectionError, OSError) as e: # 도메인 리로드 중에는 서버가 잠시 내려가 연결 거부/응답 없이 끊김이 정상 — 호출측에서 wait_ready() 후 재시도 reason = getattr(e, "reason", None) or e raise PipelineError("%s%s 연결 실패: %s (서버가 내려갔거나 도메인 리로드 중 — wait_ready() 후 재시도)" % (self.base, path, reason)) def wait_ready(self, timeout_s=180, interval_s=2.0): """포트 파일을 다시 읽으며 /api/status 가 'ready' 가 될 때까지 대기 (리컴파일·Play 전환 직후 사용).""" import time deadline = time.time() + timeout_s last = None while time.time() < deadline: try: self.refresh() st = self.status() last = st.get("status") if last == "ready": return st except PipelineError as e: last = str(e) time.sleep(interval_s) raise PipelineError("서버가 %ds 안에 ready 가 되지 않음 (마지막 상태: %s)" % (timeout_s, last)) def request(self, method, path, body=None): status, payload, _ = self.request_raw(method, path, body) try: parsed = json.loads(payload or b"null") except ValueError: parsed = {"success": False, "error": payload.decode("utf-8", "replace")} if isinstance(parsed, dict) and status >= 400: parsed.setdefault("httpStatus", status) return parsed # -- 엔드포인트 --------------------------------------------------------------- def status(self): return self.request("GET", "/api/status") def commands(self, query=None, detail="compact", tag=None): q = {"detail": detail} if query: q["query"] = query if tag: q["tag"] = tag return self.request("GET", "/api/commands?" + urllib.parse.urlencode(q)) def exec_line(self, command_line, job=False, verbose=False): """`unity command …` 와 같은 한 줄 명령을 그대로 보낸다 (서버가 토큰화·바인딩).""" body = {"commandLine": command_line} if job: body["job"] = True if verbose: body["verbose"] = True return self.request("POST", "/api/exec", body) def exec(self, command, parameters=None, job=False, verbose=False): """구조화 호출: command + parameters(dict).""" body = {"command": command, "parameters": parameters or {}} if job: body["job"] = True if verbose: body["verbose"] = True return self.request("POST", "/api/exec", body) def eval(self, code, timeout_ms=5000): return self.exec("eval", {"code": code, "timeout": timeout_ms}) def progress(self): return self.request("GET", "/api/progress") def dialog(self): return self.request("GET", "/api/dialog") def job(self, job_id): return self.request("GET", "/api/job?" + urllib.parse.urlencode({"id": job_id})) def cancel_job(self, job_id): return self.request("POST", "/api/job/cancel", {"id": job_id}) # -- 브라우저 제어판 (serve) ------------------------------------------------------- class PanelHandler(http.server.BaseHTTPRequestHandler): """/ -> control_panel.html · /api/* -> 에디터 서버 프록시 · /runtime/api/* -> 런타임 서버 프록시 · /panel/descriptor -> 연결 정보(토큰 제외).""" runtime_dir = None def log_message(self, fmt, *args): # 요청마다 찍히는 기본 로그 억제 (exec 만 표시) line = args[0] if args else "" if "/api/exec" in line: sys.stderr.write("[panel] %s\n" % line) def _send(self, code, payload, ctype="application/json; charset=utf-8"): if not isinstance(payload, (bytes, bytearray)): payload = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(payload) def _descriptor_info(self, mode): try: d = read_descriptor(mode, self.runtime_dir) except PipelineError as e: return {"available": False, "error": str(e)} info = {k: v for k, v in d.items() if k != "evalToken"} info["available"] = True return info def do_GET(self): url = urllib.parse.urlparse(self.path) if url.path in ("/", "/index.html"): if not PANEL_HTML.exists(): return self._send(500, {"error": "control_panel.html 없음: %s" % PANEL_HTML}) return self._send(200, PANEL_HTML.read_bytes(), "text/html; charset=utf-8") if url.path == "/panel/descriptor": return self._send(200, {"project": str(ROOT), "editor": self._descriptor_info("editor"), "runtime": self._descriptor_info("runtime")}) if url.path.startswith("/api/") or url.path.startswith("/runtime/api/"): return self._proxy("GET") return self._send(404, {"error": "not found"}) def do_POST(self): url = urllib.parse.urlparse(self.path) if url.path.startswith("/api/") or url.path.startswith("/runtime/api/"): return self._proxy("POST") return self._send(404, {"error": "not found"}) def _proxy(self, method): mode = "runtime" if self.path.startswith("/runtime/") else "editor" target = self.path[len("/runtime"):] if mode == "runtime" else self.path body = None if method == "POST": length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else b"{}" try: client = PipelineClient(mode, self.runtime_dir, timeout=120) # 매 요청 새 토큰 status, payload, ctype = client.request_raw(method, target, body) except PipelineError as e: return self._send(503, {"success": False, "error": str(e), "mode": mode}) return self._send(status, payload, ctype) def serve(port=DEFAULT_PANEL_PORT, runtime_dir=None, open_browser=True): PanelHandler.runtime_dir = runtime_dir server = http.server.ThreadingHTTPServer(("127.0.0.1", port), PanelHandler) url = "http://127.0.0.1:%d/" % port print("[panel] WL Pipeline 제어판: %s (프로젝트: %s) Ctrl+C 로 종료" % (url, ROOT)) if open_browser: webbrowser.open(url) try: server.serve_forever() except KeyboardInterrupt: print("\n[panel] 종료") # -- CLI ------------------------------------------------------------------------ def _print(obj): print(json.dumps(obj, ensure_ascii=False, indent=2)) def main(argv=None): ap = argparse.ArgumentParser(description="Unity Pipeline HTTP 클라이언트 (WL)") ap.add_argument("--runtime", action="store_true", help="런타임 서버(.unity-pipeline-runtime-port) 대상") ap.add_argument("--runtime-dir", help="런타임 포트 파일 폴더 (기본: 프로젝트 루트)") sub = ap.add_subparsers(dest="cmd") sub.required = True sub.add_parser("status", help="GET /api/status") sub.add_parser("descriptor", help="포트 파일 내용 (토큰 마스킹)") p = sub.add_parser("commands", help="GET /api/commands") p.add_argument("query", nargs="?") p.add_argument("--tag") p.add_argument("--detail", default="compact", choices=["compact", "full"]) p = sub.add_parser("exec", help="POST /api/exec (한 줄 명령)") p.add_argument("command_line", nargs="+", help='예: editor_status · "wl_timescale --scale 0.5"') p.add_argument("--job", action="store_true", help="분리 작업으로 제출하고 job id 반환") p.add_argument("--verbose", action="store_true") p = sub.add_parser("eval", help="C# 한 줄 평가 (eval)") p.add_argument("code") p = sub.add_parser("serve", help="브라우저 제어판 + 프록시") p.add_argument("--port", type=int, default=DEFAULT_PANEL_PORT) p.add_argument("--no-browser", action="store_true") args = ap.parse_args(argv) mode = "runtime" if args.runtime else "editor" try: if args.cmd == "serve": serve(args.port, args.runtime_dir, not args.no_browser) return 0 if args.cmd == "descriptor": d = read_descriptor(mode, args.runtime_dir) d["evalToken"] = d.get("evalToken", "")[:4] + "...(masked)" _print(d) return 0 client = PipelineClient(mode, args.runtime_dir) if args.cmd == "status": _print(client.status()) elif args.cmd == "commands": _print(client.commands(args.query, args.detail, args.tag)) elif args.cmd == "exec": res = client.exec_line(" ".join(args.command_line), job=args.job, verbose=args.verbose) _print(res) return 0 if res.get("success") else 6 elif args.cmd == "eval": res = client.eval(args.code) _print(res) return 0 if res.get("success") else 6 except PipelineError as e: print("[pipeline] %s" % e, file=sys.stderr) return 7 return 0 if __name__ == "__main__": sys.exit(main() or 0)