Two new specs:
- 2026-05-24-zacus-hub-app.md — architecture, sprints, auth,
open decisions for the SwiftUI hub app + gateway.
- 2026-05-24-firmware-scenario-hotload.md — firmware Phase 2
plan for /game/scenario + ESP-NOW mesh relay (the master
side has shipped on the submodule; the relay + peer
receivers + scenario_engine_reload remain to do).
Submodule ESP32_ZACUS bumped to d220d94 on feat/idf-migration
(POST /game/scenario hot-load on master, scenario_server.c on
BOX-3, ota_server max_uri_handlers 8->16, sdkconfig.qemu).
3.6 KiB
Zacus Hub — Native macOS/iOS App + Gateway
Status: scaffolded 2026-05-24 — foundation only, modes need implementation.
Owner: L'électron rare
Repos: monorepo (apps/zacus-hub/ SwiftUI, tools/zacus-gateway/ FastAPI).
Goal
A single SwiftUI multiplatform app (macOS 14+, iOS 17+) acting as a unified hub for the Zacus escape-room ecosystem, with three modes:
| Mode | Audience | Backend surfaces |
|---|---|---|
| Game-master | operator in the room | gateway /state, /hint, /scene/*, /esp32/* |
| Companion | players (outside-room extension) | gateway /voice/*, /qr, /hint/auto |
| Studio | author | gateway /scenario/*, file sync of game/scenarios/*.yaml |
Switching modes is a top-level tab (iPad/macOS) or sheet (iPhone).
Why a new gateway
The 3 modes need an aggregated read-model (current scene + ESP32 state + last
hint + voice session id) and a uniform auth surface. Calling 4 backends
(voice-bridge :8200, hints :8311, ESP32 REST, scenario files) directly
from the app would duplicate logic and leak Tailscale addresses into Swift.
The gateway is thin (FastAPI, ~300 LOC target): token auth, request fan-out, WebSocket multiplex for live state, no business logic.
Architecture
iPhone / iPad / Mac (SwiftUI)
│ bearer token (Keychain)
▼
tools/zacus-gateway (FastAPI :8400)
├── /v1/auth/ping token check
├── /v1/state SSE/WS live game state
├── /v1/gm/* game-master actions
├── /v1/companion/voice/* proxies voice-bridge :8200
├── /v1/companion/hint proxies hints :8311
├── /v1/studio/scenario YAML read/write
└── /v1/esp32/* proxies master REST (when on LAN)
│
▼
voice-bridge :8200 | hints :8311 | ESP32 master | file system
Auth (MVP)
- Single shared bearer token, generated by
tools/zacus-gateway/gen_token.py. - Stored in iOS/macOS Keychain (via
KeychainAccessor raw Security.framework). - Gateway validates
Authorization: Bearer <token>against env varZACUS_HUB_TOKEN. - Transport: Tailscale tailnet
electron-rare— no TLS termination needed for v1 (Tailscale provides WireGuard).
Sprints
| # | Scope | Done when |
|---|---|---|
| 0 | Scaffold (this commit) | Gateway serves /v1/auth/ping, app builds on both targets, 3 empty tabs render |
| 1 | Game-master read-only | Live state WS, scene list, ESP32 health visible |
| 2 | Game-master actions | Trigger hint, advance scene, replay NPC line |
| 3 | Companion voice | Push-to-talk → voice-bridge → response audio + transcript |
| 4 | Companion hints | Request hint, anti-cheat respected, adaptive level |
| 5 | Studio read | List scenarios, view YAML, syntax-highlight |
| 6 | Studio edit | Edit YAML in editor, validate via compile_runtime3.py |
| 7 | Polish | TestFlight, icons, App Store Connect (internal distribution) |
Build
# Gateway
cd tools/zacus-gateway
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
ZACUS_HUB_TOKEN=$(python gen_token.py) uvicorn main:app --port 8400
# App (requires Xcode 16+ and XcodeGen)
brew install xcodegen
cd apps/zacus-hub
xcodegen generate
open ZacusHub.xcodeproj
Open decisions
- Distribution: TestFlight internal vs ad-hoc IPA? Defer to sprint 7.
- Live state transport: SSE (simpler) vs WebSocket (bidirectional). Default to WS so companion voice can share the same channel.
- Studio offline edit: sync via gateway file-sync vs git checkout on device? Sprint 6 decision.