303 lines
12 KiB
Python
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()
|