Files
L'électron rare a73647783b docs(specs): hub app + firmware hot-load plan
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).
2026-05-24 10:52:08 +02:00

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 KeychainAccess or raw Security.framework).
  • Gateway validates Authorization: Bearer <token> against env var ZACUS_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.