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.
70 lines
2.2 KiB
Swift
70 lines
2.2 KiB
Swift
import SwiftUI
|
|
|
|
enum HubMode: String, CaseIterable, Identifiable {
|
|
case gameMaster, companion, studio
|
|
var id: String { rawValue }
|
|
var label: String {
|
|
switch self {
|
|
case .gameMaster: return "Game-master"
|
|
case .companion: return "Companion"
|
|
case .studio: return "Studio"
|
|
}
|
|
}
|
|
var systemImage: String {
|
|
switch self {
|
|
case .gameMaster: return "gauge.with.dots.needle.bottom.50percent"
|
|
case .companion: return "waveform.and.mic"
|
|
case .studio: return "doc.text.below.ecg"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RootView: View {
|
|
@EnvironmentObject var session: HubSession
|
|
@State private var mode: HubMode = .gameMaster
|
|
@State private var showSettings = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
#if os(macOS)
|
|
NavigationSplitView {
|
|
List(HubMode.allCases, selection: Binding($mode)) { item in
|
|
Label(item.label, systemImage: item.systemImage).tag(item)
|
|
}
|
|
.navigationTitle("Zacus Hub")
|
|
} detail: {
|
|
content
|
|
}
|
|
#else
|
|
TabView(selection: $mode) {
|
|
ForEach(HubMode.allCases) { item in
|
|
contentView(for: item)
|
|
.tabItem { Label(item.label, systemImage: item.systemImage) }
|
|
.tag(item)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button { showSettings = true } label: { Image(systemName: "gearshape") }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) { SettingsView() }
|
|
}
|
|
|
|
@ViewBuilder private var content: some View { contentView(for: mode) }
|
|
|
|
@ViewBuilder private func contentView(for mode: HubMode) -> some View {
|
|
switch mode {
|
|
case .gameMaster: GameMasterView()
|
|
case .companion: CompanionView()
|
|
case .studio: StudioView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Binding where Value == HubMode {
|
|
init(_ source: Binding<HubMode>) { self.init(get: { source.wrappedValue }, set: { source.wrappedValue = $0 }) }
|
|
}
|