From d47e4f2eadb86686572bd2f38eae2add88b5a53c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 13 Aug 2026 12:16:05 +0100 Subject: [PATCH] FuelBoard Copy Editor: web editor + Save & Build for Localizable.strings --- .gitignore | 4 + README.md | 74 ++++++++++++++ editor.py | 270 +++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 161 +++++++++++++++++++++++++++++ scripts/run.sh | 8 ++ 5 files changed, 517 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 editor.py create mode 100644 index.html create mode 100755 scripts/run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e76be7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.token +.venv/ +__pycache__/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b48313 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# FuelBoard Copy Editor + +A tiny, self-contained web editor for FuelBoard's `Localizable.strings`, +running on the Mac Mini. Edit copy from the iPhone (Safari), save it straight +into the FuelBoard repo, or save **and** kick off an immediate Release build + +IPA. + +## Why + +All FuelBoard copy lives in `FuelBoard/en.lproj/Localizable.strings` (the +`Localizable.strings` refactor). This editor lets you tweak wording from the +phone without touching git, the Mac, or the build pipeline by hand — while the +Mac Mini repo stays the canonical source of truth and Gitea stays a backup. + +## Run + +```bash +./scripts/run.sh # port 8790 (or EDITOR_PORT) +``` + +Then on the phone (same Wi-Fi, or anywhere via Tailscale): + +``` +http://192.168.1.131:8790/ +http://100.120.217.98:8790/ +``` + +Enter the token once (kept in the browser's localStorage afterwards): + +```bash +cat /Users/apt/workspace/fuelboard-editor/.token +``` + +Set `TOKEN=...` in the environment to pin your own. + +## Endpoints + +| Route | What it does | +|-------------|-----------------------------------------------------------| +| `GET /` | The editor page (no auth) | +| `GET /file` | Current strings file + git dirty/clean banner | +| `POST /save`| Write the file, then `plutil -lint` it | +| `POST /save-build` | Write the file and start a background build job | +| `GET /job/` | Job progress (polled by the page, 2 s) | + +Auth: `X-Key: ` on every route except the HTML page. + +## What a "Save & Build" does + +1. writes `FuelBoard/en.lproj/Localizable.strings` +2. `plutil -lint` — aborts if the file is invalid +3. `xcodebuild` Release, unsigned (same command as the manual pipeline) +4. packages a fresh IPA to `/tmp/ipa-serve/FuelBoard.ipa` +5. serves it at `http://192.168.1.131:8765/FuelBoard.ipa` + +The page polls the job and shows the build log live. + +## Safety + +- Only ever writes the one strings file — no arbitrary paths. +- It never commits or pushes: Gitea stays a backup, and commits happen + afterwards via the normal workflow once the copy is approved. +- One build at a time; the page shows the git dirty/clean state so you never + save over in-flight work. +- Token required for every write/build call. + +## Layout + +``` +editor.py single-file server (stdlib only: http.server, subprocess) +index.html the editor page (mobile-first, dark) +scripts/run.sh launcher +.token auto-generated shared token (gitignored) +``` diff --git a/editor.py b/editor.py new file mode 100644 index 0000000..0c425e9 --- /dev/null +++ b/editor.py @@ -0,0 +1,270 @@ +#!/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() diff --git a/index.html b/index.html new file mode 100644 index 0000000..9b1dcca --- /dev/null +++ b/index.html @@ -0,0 +1,161 @@ + + + + + +FuelBoard Copy Editor + + + +

⛽ FuelBoard Copy Editor

+
·
+
+ +
+ + +
+
+
+ + + + diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..58bf12a --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# FuelBoard Copy Editor launcher +set -euo pipefail +cd "$(dirname "$0")/.." + +PORT="${EDITOR_PORT:-8790}" +export PORT +exec python3 editor.py