#!/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 /strings-format.js -> .strings parse/serialize helpers (pure JS) 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 re import secrets import shutil import subprocess import threading import time import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse # -------------------------------------------------------------------------- # 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" def parse_sections(content: str) -> list: """Find single-line comment banners like /* Stations tab */ so the editor can offer a jump-to-section dropdown. The multi-line header comment at the top never matches (it doesn't end with */ on one line).""" sections = [] for i, line in enumerate(content.splitlines(), start=1): m = re.match(r"^/\* (.+) \*/$", line.strip()) if m: sections.append({"name": m.group(1).strip(), "line": i}) return sections # -------------------------------------------------------------------------- # 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__).parent / "index.html").read_text(encoding="utf-8") FORMAT_JS = (Path(__file__).parent / "strings-format.js").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): path = urlparse(self.path).path # strip ?query so /?key=… still routes if path in ("/", "/index.html"): self._send(200, PAGE.encode(), "text/html; charset=utf-8") return if path == "/strings-format.js": self._send(200, FORMAT_JS.encode(), "text/javascript; charset=utf-8") return if 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), "sections": parse_sections(content)}) return if 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()