File size: 8,908 Bytes
b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b 8d9c9a2 3cbfe5b 763ddc3 3cbfe5b b60b695 3cbfe5b 8d9c9a2 b60b695 3cbfe5b b60b695 763ddc3 b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 3cbfe5b b60b695 2c8569d b60b695 3cbfe5b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """
Riaslink Port Builder — FastAPI backend.
Replaces an earlier Gradio-based UI entirely. Gradio 5.9.1 turned out to
have several internal route/schema bugs on this deployment (a localhost-
reachability probe that crashed under HF Spaces' container networking, a
JSON-schema-to-Python-type conversion bug that 500'd on every page load,
and a mount_gradio_app() path that silently broke form submission) — each
fix uncovered a new failure in a different internal code path. Rather than
keep patching Gradio internals we don't control, this is a small,
self-contained FastAPI app: a handful of plain routes, no framework-level
"magic" deciding how the browser and server talk to each other.
Routes:
GET / the build form (static/index.html)
POST /api/build start a build, returns {build_id}
GET /api/build/{id}/stream Server-Sent Events: live log lines
GET /api/build/{id}/download the finished APK, once ready
All the actual build logic (zip handling, plugin injection, Gradle
invocation) lives in build_pipeline.py and is untouched by this rewrite.
"""
from __future__ import annotations
import asyncio
import json
import shutil
import threading
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, Form, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from build_pipeline import BuildRequest, run_build
STATIC_DIR = Path(__file__).parent / "static"
UPLOAD_DIR = Path("/tmp/riaslink-uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Riaslink Port Builder")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ---------------------------------------------------------------------------
# In-memory build tracking
# ---------------------------------------------------------------------------
# One process, no external queue/database — builds are tracked in a plain
# dict guarded by a lock. This matches the previous Gradio version's own
# approach (an in-memory list capping kept build directories) and is
# sufficient for a single-instance Space; it does not survive a restart,
# which is fine since an in-progress build wouldn't survive one either.
BuildStatus = Literal["running", "success", "failed"]
@dataclass
class BuildState:
status: BuildStatus = "running"
log_lines: list[str] = field(default_factory=list)
apk_path: str | None = None
error_message: str | None = None
log_tail: str = ""
_builds: dict[str, BuildState] = {}
_builds_lock = threading.Lock()
# Same cap/eviction idea as the previous version: keep disk usage bounded
# across a long-running process by deleting the oldest finished builds'
# working directories once there are too many, rather than never cleaning
# up. Tracks work_dir roots, not the state dict entries themselves (state
# stays cheap — it's just log strings — so it isn't evicted).
_MAX_KEPT_BUILD_DIRS = 20
_kept_build_dirs: list[Path] = []
def _remember_and_trim_build_dir(apk_path: str) -> None:
# app/build/outputs/apk/<variant>/x.apk -> work_dir is 5 levels up.
build_dir = Path(apk_path).parents[5]
_kept_build_dirs.append(build_dir)
while len(_kept_build_dirs) > _MAX_KEPT_BUILD_DIRS:
oldest = _kept_build_dirs.pop(0)
shutil.rmtree(oldest, ignore_errors=True)
def _run_build_in_background(build_id: str, req: BuildRequest, upload_path: Path) -> None:
def on_log(msg: str) -> None:
with _builds_lock:
_builds[build_id].log_lines.append(msg)
result = run_build(req, log_callback=on_log)
with _builds_lock:
state = _builds[build_id]
state.log_tail = result.log_tail
if result.success:
state.status = "success"
state.apk_path = result.apk_path
_remember_and_trim_build_dir(result.apk_path)
else:
state.status = "failed"
state.error_message = result.error_message
if result.log_tail:
state.log_lines.append("\n--- Gradle günlüğünün son satırları ---")
state.log_lines.extend(result.log_tail.splitlines())
# The uploaded zip is only needed while run_build() is extracting it;
# safe to remove once the build has fully finished either way.
upload_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
def _sse_event(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@app.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
html_path = STATIC_DIR / "index.html"
return HTMLResponse(html_path.read_text(encoding="utf-8"))
@app.post("/api/build")
async def start_build(
www_zip: UploadFile,
# Blank by default: build_pipeline derives the app name from the game's
# own index.html <title> and the package id from that name. Anything
# typed into the form still wins — see _resolve_identity() there.
app_name: str = Form(""),
application_id: str = Form(""),
version_name: str = Form("1.0.0"),
version_code: int = Form(1),
fps_default_visible: bool = Form(False),
accent_hex: str = Form("#e8a343"),
build_variant: str = Form("debug"),
# Licensing / protection. All optional — leaving them blank builds a
# perfectly working app whose premium sections stay locked, which is what
# a quick test build wants. See BuildRequest in build_pipeline.py.
license_server: str = Form(""),
license_public_key: str = Form(""),
expected_signature: str = Form(""),
encrypt_assets: bool = Form(False),
) -> dict:
build_id = uuid.uuid4().hex
upload_path = UPLOAD_DIR / f"{build_id}.zip"
with upload_path.open("wb") as f:
shutil.copyfileobj(www_zip.file, f)
req = BuildRequest(
www_zip_path=str(upload_path),
# Passed through empty on purpose when unset — run_build() treats an
# empty string as "derive this from the game itself".
app_name=app_name.strip(),
application_id=application_id.strip(),
version_name=version_name.strip() or "1.0.0",
version_code=version_code,
fps_default_visible=fps_default_visible,
accent_hex=accent_hex.strip() or "#e8a343",
build_variant=build_variant,
license_server=license_server.strip(),
license_public_key=license_public_key.strip(),
expected_signature=expected_signature.strip(),
encrypt_assets=encrypt_assets,
)
with _builds_lock:
_builds[build_id] = BuildState()
thread = threading.Thread(
target=_run_build_in_background,
args=(build_id, req, upload_path),
daemon=True,
)
thread.start()
return {"build_id": build_id}
@app.get("/api/build/{build_id}/stream")
async def stream_build_log(build_id: str) -> StreamingResponse:
async def event_generator():
sent = 0
while True:
with _builds_lock:
state = _builds.get(build_id)
if state is None:
yield _sse_event({"type": "error", "message": "Bilinmeyen build id."})
return
new_lines = state.log_lines[sent:]
sent = len(state.log_lines)
status = state.status
apk_ready = state.apk_path is not None
error_message = state.error_message
for line in new_lines:
yield _sse_event({"type": "log", "line": line})
if status != "running":
yield _sse_event({
"type": "done",
"status": status,
"download_url": f"/api/build/{build_id}/download" if apk_ready else None,
"error_message": error_message,
})
return
await asyncio.sleep(0.5)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.get("/api/build/{build_id}/download")
async def download_apk(build_id: str):
with _builds_lock:
state = _builds.get(build_id)
if state is None or state.apk_path is None:
return HTMLResponse("APK bulunamadı ya da build henüz tamamlanmadı.", status_code=404)
apk_path = Path(state.apk_path)
if not apk_path.exists():
return HTMLResponse("APK dosyası artık mevcut değil (temizlenmiş olabilir).", status_code=404)
return FileResponse(
apk_path,
media_type="application/vnd.android.package-archive",
filename=apk_path.name,
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)
|