3babe2b3ab
New multiplatform SwiftUI app under apps/zacus-hub/ generated via
XcodeGen (project.yml). Three modes share a HubSession actor that
talks to tools/zacus-gateway over Tailscale-only HTTP/WS.
Game-master: live state stream (WebSocket /v1/state/ws), backend
health badges, hint trigger, journal.
Companion: push-to-talk via AVAudioRecorder (M4A AAC 16 kHz),
fallback file picker, hint request, transcript via voice-bridge
proxy.
Studio: YAML editor with validate/save (existing /v1/studio
routes), plus a Scratch tab hosting a Blockly + zelos-renderer
workspace in WKWebView. 38 custom Zacus block types across 10
categories (Scènes, NPC, Audio, LCD, Hardware ESP32, ESP-NOW,
BOX-3, M5, PLIP, Logique), .sb3 export/import via JSZip (procedure
calls + embedded YAML for lossless round-trip), Flasher sheet that
calls /v1/flash/{board} with auto/hot/cold strategies and shows
per-board step results including the cold-flash idf.py command.
ATS exception domains cover the Tailscale CGNAT range plus the
electron-server / studio / macm1 hostnames so the WebView and
URLSession can hit plain HTTP on the tailnet without TLS.
Token stays in the Keychain (cc.saillant.zacus.hub) — never
committed to source.
148 lines
6.2 KiB
Swift
148 lines
6.2 KiB
Swift
import SwiftUI
|
|
|
|
struct FlasherSheet: View {
|
|
@EnvironmentObject var session: HubSession
|
|
@Environment(\.dismiss) private var dismiss
|
|
let scenarioName: String
|
|
|
|
@State private var boards: [BoardInfo] = []
|
|
@State private var selected: Set<String> = []
|
|
@State private var strategy: String = "auto"
|
|
@State private var results: [String: FlashResult] = [:]
|
|
@State private var running: Set<String> = []
|
|
@State private var error: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Scénario") {
|
|
Text(scenarioName).font(.body.monospaced())
|
|
Picker("Stratégie", selection: $strategy) {
|
|
Text("Auto (hot si IP, sinon cold)").tag("auto")
|
|
Text("Hot — POST IR via WiFi").tag("hot")
|
|
Text("Cold — rebuild + flash série").tag("cold")
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
Section("Boards cibles") {
|
|
if boards.isEmpty {
|
|
Text("Aucun board déclaré — vérifie `tools/zacus-gateway/boards.yaml` sur electron-server.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
ForEach(boards) { board in
|
|
boardRow(board)
|
|
}
|
|
}
|
|
if let error { Section { Text(error).foregroundStyle(.red).font(.caption) } }
|
|
if !results.isEmpty {
|
|
Section("Résultats") {
|
|
ForEach(Array(results.keys).sorted(), id: \.self) { name in
|
|
if let r = results[name] { resultBlock(r) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Flasher")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) { Button("Fermer") { dismiss() } }
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Flasher") { Task { await flashAll() } }
|
|
.disabled(selected.isEmpty || !running.isEmpty)
|
|
}
|
|
}
|
|
.task { await loadBoards() }
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 640, minHeight: 520)
|
|
#endif
|
|
}
|
|
|
|
private func boardRow(_ board: BoardInfo) -> some View {
|
|
HStack(alignment: .top) {
|
|
Toggle(isOn: Binding(
|
|
get: { selected.contains(board.name) },
|
|
set: { on in if on { selected.insert(board.name) } else { selected.remove(board.name) } }
|
|
)) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(board.label).font(.body)
|
|
HStack(spacing: 8) {
|
|
Text(board.type).font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
if let ip = board.ip { Text(ip).font(.caption2.monospaced()).foregroundStyle(.secondary) }
|
|
else if !board.espnow_relay_peers.isEmpty {
|
|
Text("via ESP-NOW relay").font(.caption2).foregroundStyle(.orange)
|
|
} else {
|
|
Text("cold-flash uniquement").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if !board.espnow_relay_peers.isEmpty {
|
|
Text("Pousse en mesh: \(board.espnow_relay_peers.joined(separator: ", "))")
|
|
.font(.caption2).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
}
|
|
Spacer()
|
|
if running.contains(board.name) {
|
|
ProgressView().scaleEffect(0.7)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resultBlock(_ r: FlashResult) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Image(systemName: r.ok ? "checkmark.circle.fill" : "exclamationmark.octagon.fill")
|
|
.foregroundStyle(r.ok ? .green : .red)
|
|
Text("\(r.board) · \(r.strategy_used)").font(.subheadline.bold())
|
|
}
|
|
ForEach(Array(r.steps.enumerated()), id: \.offset) { _, s in
|
|
HStack(alignment: .top) {
|
|
Text(statusGlyph(s.status)).foregroundStyle(statusColor(s.status))
|
|
Text(s.label).font(.caption.bold())
|
|
Text(s.detail).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let cmd = r.cold_command, !cmd.isEmpty {
|
|
Text("Commande locale:").font(.caption.bold()).padding(.top, 2)
|
|
Text(cmd)
|
|
.font(.caption.monospaced())
|
|
.textSelection(.enabled)
|
|
.padding(6)
|
|
.background(Color.black.opacity(0.08))
|
|
.cornerRadius(4)
|
|
}
|
|
if !r.relayed_to.isEmpty {
|
|
Text("Mesh: relayé vers \(r.relayed_to.joined(separator: ", "))")
|
|
.font(.caption).foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func statusGlyph(_ s: String) -> String {
|
|
switch s { case "ok": "✓"; case "warn": "⚠"; case "skip": "◦"; default: "✗" }
|
|
}
|
|
private func statusColor(_ s: String) -> Color {
|
|
switch s { case "ok": .green; case "warn": .orange; case "skip": .secondary; default: .red }
|
|
}
|
|
|
|
private func loadBoards() async {
|
|
do { boards = try await session.api.listBoards(); error = nil }
|
|
catch let err { error = err.localizedDescription }
|
|
}
|
|
|
|
private func flashAll() async {
|
|
for name in selected {
|
|
running.insert(name)
|
|
do {
|
|
let r = try await session.api.flashBoard(name, scenario: scenarioName, strategy: strategy)
|
|
results[name] = r
|
|
} catch let err {
|
|
results[name] = FlashResult(board: name, strategy_used: strategy, ok: false,
|
|
steps: [FlashStep(label: "request", status: "error", detail: err.localizedDescription)],
|
|
ir_path: nil, cold_command: nil, relayed_to: [])
|
|
}
|
|
running.remove(name)
|
|
}
|
|
}
|
|
}
|