Files

303 lines
12 KiB
Python

#!/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/<id> -> 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).
# Sign the APPEX FIRST with Widget.entitlements, then the app
# with App.entitlements. A bare `codesign --deep -s -` leaves
# the appex UNSIGNED and embeds NO entitlements, so SideStore
# re-signs without application-groups/keychain groups and the
# widget cannot read favourites from the shared container.
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")
appex = work / "Payload" / "FuelBoard.app" / "PlugIns" / "FuelBoardWidgets.appex"
subprocess.run(["codesign", "--force", "-s", "-",
"--entitlements", str(REPO / "Config" / "Widget.entitlements"),
str(appex)],
check=True, capture_output=True)
subprocess.run(["codesign", "--force", "-s", "-",
"--entitlements", str(REPO / "Config" / "App.entitlements"),
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()