#!/usr/bin/env python3 """FuelBoard Copy Editor — a tiny self-contained web editor for Localizable.strings that writes straight into the FuelBoard repo on the Mac Mini and can kick off an immediate Release build + IPA. Why stdlib only: no pip installs, one file to read, easy to see how it works. Routes: GET / -> the editor page (HTML) GET /file -> current Localizable.strings + git dirty banner POST /save -> write the file + plutil lint {body: content} POST /save-build -> write the file, then start a background build job GET /job/ -> job progress (polled by the page) Auth: every route except the HTML page needs the shared token. The page sends it as the X-Key header (kept in localStorage after first entry). Config via env: FUELBOARD_REPO, TOKEN, PORT (default 8790). """ import json import os import secrets import shutil import subprocess import threading import time import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path # -------------------------------------------------------------------------- # Config # -------------------------------------------------------------------------- REPO = Path(os.environ.get("FUELBOARD_REPO", "/Users/apt/workspace/fuelboard")) STRINGS_FILE = REPO / "FuelBoard" / "en.lproj" / "Localizable.strings" IPA_SERVE_DIR = Path(os.environ.get("IPA_SERVE_DIR", "/tmp/ipa-serve")) IPA_PATH = IPA_SERVE_DIR / "FuelBoard.ipa" PORT = int(os.environ.get("PORT", "8790")) # Token: env wins, else .token file (auto-generated on first run). TOKEN_FILE = Path(__file__).with_name(".token") TOKEN = os.environ.get("TOKEN", "").strip() if not TOKEN: if TOKEN_FILE.exists(): TOKEN = TOKEN_FILE.read_text().strip() else: TOKEN = secrets.token_hex(16) TOKEN_FILE.write_text(TOKEN + "\n") # -------------------------------------------------------------------------- # Git helpers # -------------------------------------------------------------------------- def git(*args: str, cwd: Path = REPO) -> str: """Run a git command in the repo; return stdout, stripped.""" out = subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30 ) return out.stdout.strip() def git_dirty_files() -> list[str]: """Names of files with uncommitted changes (so the editor can warn about editing over in-flight work).""" out = subprocess.run( ["git", "status", "--porcelain"], cwd=REPO, capture_output=True, text=True ) return [line[3:] if line.startswith("?? ") else line[3:] for line in out.stdout.splitlines() if line.strip()] def git_head() -> str: return git("log", "--oneline", "-1") or "no commits" # -------------------------------------------------------------------------- # Build job (runs in a background thread so the phone doesn't wait on it) # -------------------------------------------------------------------------- JOBS: dict[str, dict] = {} # job_id -> state JOBS_LOCK = threading.Lock() BUILD_LOCK = threading.Lock() # one build at a time def append_lines(state: dict, chunk: str) -> None: """Keep a rolling window of the last 60 log lines per job.""" lines = chunk.splitlines() if not lines: return state["lines"].extend(lines) state["lines"] = state["lines"][-60:] def run_shell(state: dict, cmd: list[str], cwd: Path = REPO) -> int: """Run a command, streaming its output into the job's log.""" proc = subprocess.Popen( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) for line in proc.stdout: append_lines(state, line.rstrip()) proc.wait() return proc.returncode def start_build_job(content: str) -> str: """Write the file, then build + package in a background thread.""" job_id = uuid.uuid4().hex[:12] state = {"lines": [], "running": True, "done": False, "success": False, "lint": None, "ipa_size": None, "finished_at": None} with JOBS_LOCK: JOBS[job_id] = state def job(): try: state["lines"].append(f"repo: {REPO} head: {git_head()}") # 1. Save the edit STRINGS_FILE.write_text(content, encoding="utf-8") append_lines(state, f"saved {STRINGS_FILE.relative_to(REPO)}") # 2. Lint the strings file before anything else lint = subprocess.run(["plutil", "-lint", str(STRINGS_FILE)], capture_output=True, text=True) state["lint"] = lint.stdout.strip() or lint.stderr.strip() append_lines(state, state["lint"]) if lint.returncode != 0: state["success"] = False append_lines(state, "✗ strings file is invalid — build aborted") return # 3. Release build (same command as the manual pipeline) append_lines(state, "building (Release, unsigned)…") rc = run_shell(state, [ "xcodebuild", "-project", "FuelBoard.xcodeproj", "-scheme", "FuelBoard", "-configuration", "Release", "-destination", "generic/platform=iOS", "-derivedDataPath", "build", "CODE_SIGNING_ALLOWED=NO", "build", ]) if rc != 0: append_lines(state, "✗ BUILD FAILED — see lines above") return # 4. Package the IPA (fresh zip; remove the old one first) app = REPO / "build" / "Build" / "Products" / "Release-iphoneos" / "FuelBoard.app" work = Path("/tmp/ipa-work") shutil.rmtree(work, ignore_errors=True) (work / "Payload").mkdir(parents=True) shutil.copytree(app, work / "Payload" / "FuelBoard.app") subprocess.run(["codesign", "--force", "--deep", "-s", "-", str(work / "Payload" / "FuelBoard.app")], check=True, capture_output=True) IPA_SERVE_DIR.mkdir(parents=True, exist_ok=True) if IPA_PATH.exists(): IPA_PATH.unlink() subprocess.run(["zip", "-rq", str(IPA_PATH), "Payload/"], cwd=work, check=True) size = IPA_PATH.stat().st_size if IPA_PATH.exists() else 0 state["ipa_size"] = size append_lines(state, f"✓ packaged {IPA_PATH} ({size/1e6:.2f} MB)") state["success"] = True except Exception as exc: # noqa: BLE001 — job must never hang the UI append_lines(state, f"✗ job error: {exc}") finally: state["running"] = False state["done"] = True state["finished_at"] = time.strftime("%H:%M:%S") threading.Thread(target=job, daemon=True).start() return job_id # -------------------------------------------------------------------------- # HTTP layer # -------------------------------------------------------------------------- PAGE = (Path(__file__).with_name("index.html")).read_text(encoding="utf-8") class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): # quieter logs pass def _send(self, code: int, body: bytes, ctype: str = "application/json"): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _json(self, code: int, obj: dict): self._send(code, json.dumps(obj).encode(), "application/json") def _authorized(self) -> bool: return self.headers.get("X-Key") == TOKEN # -- routes ----------------------------------------------------------- def do_GET(self): if self.path in ("/", "/index.html"): self._send(200, PAGE.encode(), "text/html; charset=utf-8") return if self.path == "/file": if not self._authorized(): self._json(401, {"error": "token required"}) return try: content = STRINGS_FILE.read_text(encoding="utf-8") except FileNotFoundError: self._json(404, {"error": f"missing {STRINGS_FILE}"}) return self._json(200, {"content": content, "dirty": git_dirty_files(), "head": git_head(), "repo": str(REPO)}) return if self.path.startswith("/job/"): if not self._authorized(): self._json(401, {"error": "token required"}) return job_id = self.path.rsplit("/", 1)[-1] with JOBS_LOCK: state = JOBS.get(job_id) if state is None: self._json(404, {"error": "unknown job"}) return self._json(200, dict(state)) return self._json(404, {"error": "not found"}) def do_POST(self): if not self._authorized(): self._json(401, {"error": "token required"}) return length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") if length else "" if self.path == "/save": STRINGS_FILE.write_text(body, encoding="utf-8") lint = subprocess.run(["plutil", "-lint", str(STRINGS_FILE)], capture_output=True, text=True) ok = lint.returncode == 0 self._json(200, {"saved": True, "lint_ok": ok, "lint": lint.stdout.strip() or lint.stderr.strip(), "dirty": git_dirty_files()}) return if self.path == "/save-build": if BUILD_LOCK.locked(): self._json(409, {"error": "a build is already running"}) return with BUILD_LOCK: job_id = start_build_job(body) self._json(200, {"job_id": job_id}) return self._json(404, {"error": "not found"}) def main(): print(f"FuelBoard Copy Editor") print(f" repo : {REPO}") print(f" file : {STRINGS_FILE}") print(f" url : http://0.0.0.0:{PORT}/ (phone: http://192.168.1.131:{PORT}/ or http://100.120.217.98:{PORT}/)") print(f" token: {TOKEN}") ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() if __name__ == "__main__": main()