From 3babe2b3ab6fb7f9ac19b24c1dd02460ecf3427a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=27=C3=A9lectron=20rare?=
<108685187+electron-rare@users.noreply.github.com>
Date: Sun, 24 May 2026 10:51:26 +0200
Subject: [PATCH] feat(apps): zacus-hub SwiftUI hub (macOS + iOS)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apps/zacus-hub/.gitignore | 6 +
apps/zacus-hub/README.md | 63 ++++
apps/zacus-hub/Resources/Info.plist | 80 +++++
.../zacus-hub/Resources/ZacusHub.entitlements | 14 +
apps/zacus-hub/Resources/blockly/editor.html | 200 +++++++++++
.../Resources/blockly/zacus_blocks.js | 291 ++++++++++++++++
.../Resources/blockly/zacus_codec.js | 327 ++++++++++++++++++
apps/zacus-hub/Sources/App/RootView.swift | 69 ++++
apps/zacus-hub/Sources/App/SettingsView.swift | 72 ++++
apps/zacus-hub/Sources/App/ZacusHubApp.swift | 17 +
.../Sources/Companion/CompanionView.swift | 218 ++++++++++++
.../Sources/GameMaster/GameMasterView.swift | 129 +++++++
.../Sources/Shared/AudioRecorder.swift | 112 ++++++
apps/zacus-hub/Sources/Shared/HubAPI.swift | 244 +++++++++++++
.../zacus-hub/Sources/Shared/HubSession.swift | 129 +++++++
.../Sources/Shared/KeychainStore.swift | 37 ++
.../Studio/Blocks/BlockCanvasView.swift | 294 ++++++++++++++++
.../Sources/Studio/Blocks/BlockCatalog.swift | 219 ++++++++++++
.../Sources/Studio/Blocks/BlockModel.swift | 144 ++++++++
.../Studio/Blocks/BlockPaletteView.swift | 57 +++
.../Sources/Studio/Blocks/BlockView.swift | 135 ++++++++
.../Studio/Blocks/BlocksEditorView.swift | 216 ++++++++++++
.../Sources/Studio/Blocks/BlocksYAML.swift | 218 ++++++++++++
.../Sources/Studio/Scratch/FlasherSheet.swift | 147 ++++++++
.../Studio/Scratch/ScratchEditorView.swift | 225 ++++++++++++
.../zacus-hub/Sources/Studio/StudioView.swift | 195 +++++++++++
apps/zacus-hub/project.yml | 66 ++++
27 files changed, 3924 insertions(+)
create mode 100644 apps/zacus-hub/.gitignore
create mode 100644 apps/zacus-hub/README.md
create mode 100644 apps/zacus-hub/Resources/Info.plist
create mode 100644 apps/zacus-hub/Resources/ZacusHub.entitlements
create mode 100644 apps/zacus-hub/Resources/blockly/editor.html
create mode 100644 apps/zacus-hub/Resources/blockly/zacus_blocks.js
create mode 100644 apps/zacus-hub/Resources/blockly/zacus_codec.js
create mode 100644 apps/zacus-hub/Sources/App/RootView.swift
create mode 100644 apps/zacus-hub/Sources/App/SettingsView.swift
create mode 100644 apps/zacus-hub/Sources/App/ZacusHubApp.swift
create mode 100644 apps/zacus-hub/Sources/Companion/CompanionView.swift
create mode 100644 apps/zacus-hub/Sources/GameMaster/GameMasterView.swift
create mode 100644 apps/zacus-hub/Sources/Shared/AudioRecorder.swift
create mode 100644 apps/zacus-hub/Sources/Shared/HubAPI.swift
create mode 100644 apps/zacus-hub/Sources/Shared/HubSession.swift
create mode 100644 apps/zacus-hub/Sources/Shared/KeychainStore.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlockCanvasView.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlockCatalog.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlockModel.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlockPaletteView.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlockView.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlocksEditorView.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Blocks/BlocksYAML.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Scratch/FlasherSheet.swift
create mode 100644 apps/zacus-hub/Sources/Studio/Scratch/ScratchEditorView.swift
create mode 100644 apps/zacus-hub/Sources/Studio/StudioView.swift
create mode 100644 apps/zacus-hub/project.yml
diff --git a/apps/zacus-hub/.gitignore b/apps/zacus-hub/.gitignore
new file mode 100644
index 0000000..3b7bcda
--- /dev/null
+++ b/apps/zacus-hub/.gitignore
@@ -0,0 +1,6 @@
+ZacusHub.xcodeproj/
+.build/
+DerivedData/
+*.xcuserstate
+xcuserdata/
+.DS_Store
diff --git a/apps/zacus-hub/README.md b/apps/zacus-hub/README.md
new file mode 100644
index 0000000..f11e1f3
--- /dev/null
+++ b/apps/zacus-hub/README.md
@@ -0,0 +1,63 @@
+# Zacus Hub — SwiftUI app (macOS + iOS)
+
+Foundation scaffolded 2026-05-24. Three modes: **Game-master**, **Companion**, **Studio**.
+See `docs/specs/2026-05-24-zacus-hub-app.md`.
+
+## Generate the Xcode project
+
+```bash
+brew install xcodegen # one-time
+cd apps/zacus-hub
+xcodegen generate
+open ZacusHub.xcodeproj
+```
+
+XcodeGen reads `project.yml` and produces a multiplatform `.xcodeproj`
+targeting iOS 17 + macOS 14. `ZacusHub.xcodeproj/` is git-ignored —
+regenerate after editing `project.yml` or adding source files in new
+directories.
+
+## Layout
+
+```
+apps/zacus-hub/
+├── project.yml XcodeGen spec
+├── Resources/
+│ ├── Info.plist shared (iOS leaves macOS keys harmless)
+│ └── ZacusHub.entitlements sandbox + network + mic + camera
+└── Sources/
+ ├── App/ @main, RootView (tabs/sidebar), Settings
+ ├── Shared/ HubSession, HubAPI (actor), Keychain
+ ├── GameMaster/ operator view (sprint 1+)
+ ├── Companion/ player voice + hints (sprint 3+)
+ └── Studio/ YAML browser (sprint 5+)
+```
+
+All sources live in one app target — Swift sees them as a single module,
+so SourceKit errors before `xcodegen generate` are expected noise.
+
+## Configure at first launch
+
+1. Start the gateway (`tools/zacus-gateway/README.md`), copy the token.
+2. In the app: gear icon → paste **URL** (e.g. `http://studio:8400`) +
+ **token**. Saved to Keychain (`cc.saillant.zacus.hub`).
+3. The Settings sheet shows live auth state (`/v1/auth/ping`).
+
+## Sprint 0 status
+
+| Surface | State |
+|---------|-------|
+| App skeleton (3 modes, settings, auth check) | ✅ scaffolded |
+| Gateway connection | ✅ wired |
+| Game-master state refresh | ✅ pull (stub data) |
+| Companion hint request | ✅ wired to gateway proxy |
+| Studio scenario browse | ✅ list + read |
+| Push-to-talk, live WS, scenario edit | ⏳ later sprints |
+
+## Conventions
+
+- Swift 5.10, strict concurrency on.
+- All network calls go through `HubAPI` actor — never raw URLSession in views.
+- View files own no network state — they read from `HubSession` or pass
+ via `@State` after awaiting an actor call.
+- French for user-visible strings, English for code/comments.
diff --git a/apps/zacus-hub/Resources/Info.plist b/apps/zacus-hub/Resources/Info.plist
new file mode 100644
index 0000000..f5a9f6b
--- /dev/null
+++ b/apps/zacus-hub/Resources/Info.plist
@@ -0,0 +1,80 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Zacus Hub
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ ITSAppUsesNonExemptEncryption
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSExceptionDomains
+
+ 100.112.121.126
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSTemporaryExceptionAllowsInsecureHTTPLoads
+
+
+ 100.116.92.12
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSTemporaryExceptionAllowsInsecureHTTPLoads
+
+
+ 100.78.191.52
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSExceptionMinimumTLSVersion
+ TLSv1.0
+ NSTemporaryExceptionAllowsInsecureHTTPLoads
+
+
+ electron-server
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+ macm1
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+
+ studio
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+
+
+
+ NSCameraUsageDescription
+ Scanner les QR codes du jeu.
+ NSLocalNetworkUsageDescription
+ Communication avec le gateway Zacus sur le tailnet.
+ NSMicrophoneUsageDescription
+ Push-to-talk avec Zacus dans le mode Companion.
+
+
diff --git a/apps/zacus-hub/Resources/ZacusHub.entitlements b/apps/zacus-hub/Resources/ZacusHub.entitlements
new file mode 100644
index 0000000..3fdeffc
--- /dev/null
+++ b/apps/zacus-hub/Resources/ZacusHub.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+
+
diff --git a/apps/zacus-hub/Resources/blockly/editor.html b/apps/zacus-hub/Resources/blockly/editor.html
new file mode 100644
index 0000000..0730648
--- /dev/null
+++ b/apps/zacus-hub/Resources/blockly/editor.html
@@ -0,0 +1,200 @@
+
+
+
+
+
+Zacus Blocks (Scratch-like)
+
+
+
+
+
+
+
+
+
+
+
+
+ prêt
+
+
+
+
+
+
+
+
diff --git a/apps/zacus-hub/Resources/blockly/zacus_blocks.js b/apps/zacus-hub/Resources/blockly/zacus_blocks.js
new file mode 100644
index 0000000..5702571
--- /dev/null
+++ b/apps/zacus-hub/Resources/blockly/zacus_blocks.js
@@ -0,0 +1,291 @@
+// Custom Blockly block definitions for all Zacus BlockKinds.
+// Shapes follow Scratch conventions:
+// hat : sceneStart
+// cap : sceneEnd
+// statement : both prev+next (most blocks)
+// c-block : statement input (logicIf "alors"/"sinon")
+
+const COLOR = {
+ scene: "#5085F2",
+ npc: "#D85B73",
+ audio: "#A772D9",
+ lcd: "#338CBF",
+ hardware: "#2EAD8C",
+ espnow: "#F28033",
+ box3: "#8C5A33",
+ m5: "#4D73A6",
+ plip: "#C74D8C",
+ logic: "#F2A633",
+};
+
+function def(name, init) { Blockly.Blocks[name] = { init: init }; }
+function stack(t, color) { t.setPreviousStatement(true, null); t.setNextStatement(true, null); t.setColour(color); }
+function dropdown(opts) {
+ // accept [["label","value"], …] or ["value", …]
+ return new Blockly.FieldDropdown(opts.map(o => Array.isArray(o) ? o : [o, o]));
+}
+
+// Scènes
+def("zacus_sceneStart", function () {
+ this.appendDummyInput().appendField("▶ Début de scène").appendField(new Blockly.FieldTextInput("intro"), "id");
+ this.setNextStatement(true, null); this.setColour(COLOR.scene); this.setInputsInline(true);
+});
+def("zacus_sceneEnd", function () {
+ this.appendDummyInput().appendField("⏹ Fin de scène");
+ this.setPreviousStatement(true, null); this.setColour(COLOR.scene);
+});
+def("zacus_sceneGoto", function () {
+ this.appendDummyInput().appendField("Aller à").appendField(new Blockly.FieldTextInput("next_scene"), "target");
+ stack(this, COLOR.scene); this.setInputsInline(true);
+});
+def("zacus_sceneBranch", function () {
+ this.appendDummyInput().appendField("Branche si").appendField(new Blockly.FieldTextInput("score > 5"), "condition");
+ this.appendDummyInput().appendField("alors").appendField(new Blockly.FieldTextInput("scene_a"), "ifTrue");
+ this.appendDummyInput().appendField("sinon").appendField(new Blockly.FieldTextInput("scene_b"), "ifFalse");
+ stack(this, COLOR.scene);
+});
+
+// NPC
+def("zacus_npcSay", function () {
+ this.appendDummyInput().appendField("Zacus dit")
+ .appendField(new Blockly.FieldMultilineInput("Bonjour, je suis le professeur Zacus."), "text");
+ stack(this, COLOR.npc);
+});
+def("zacus_npcWaitResponse", function () {
+ this.appendDummyInput().appendField("Attendre réponse — timeout")
+ .appendField(new Blockly.FieldNumber(10, 1, 120, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.npc); this.setInputsInline(true);
+});
+def("zacus_npcIntentMatch", function () {
+ this.appendDummyInput().appendField("Si intent =").appendField(new Blockly.FieldTextInput("yes"), "intent");
+ this.appendDummyInput().appendField("alors aller à").appendField(new Blockly.FieldTextInput("scene_x"), "then");
+ stack(this, COLOR.npc);
+});
+
+// Audio
+def("zacus_hwSoundPlay", function () {
+ this.appendDummyInput().appendField("🔊 Jouer son").appendField(new Blockly.FieldTextInput("sting_win"), "asset");
+ stack(this, COLOR.audio); this.setInputsInline(true);
+});
+def("zacus_hwAudioStop", function () {
+ this.appendDummyInput().appendField("⏸ Stopper l'audio");
+ stack(this, COLOR.audio);
+});
+def("zacus_hwAudioVolume", function () {
+ this.appendDummyInput().appendField("Volume")
+ .appendField(new Blockly.FieldNumber(70, 0, 100, 1), "level").appendField("/100");
+ stack(this, COLOR.audio); this.setInputsInline(true);
+});
+
+// LCD
+def("zacus_hwLCDText", function () {
+ this.appendDummyInput().appendField("LCD ligne")
+ .appendField(new Blockly.FieldNumber(0, 0, 32, 1), "line")
+ .appendField("texte").appendField(new Blockly.FieldTextInput("Bienvenue"), "text");
+ stack(this, COLOR.lcd); this.setInputsInline(true);
+});
+def("zacus_hwLCDClear", function () {
+ this.appendDummyInput().appendField("LCD — effacer");
+ stack(this, COLOR.lcd);
+});
+def("zacus_hwLCDImage", function () {
+ this.appendDummyInput().appendField("LCD image").appendField(new Blockly.FieldTextInput("splash"), "asset");
+ stack(this, COLOR.lcd); this.setInputsInline(true);
+});
+def("zacus_hwLCDTouchWait", function () {
+ this.appendDummyInput().appendField("Attendre tap zone").appendField(new Blockly.FieldTextInput("btn_yes"), "zone")
+ .appendField("timeout").appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.lcd); this.setInputsInline(true);
+});
+
+// Hardware (ESP32 générique)
+def("zacus_hwServo", function () {
+ this.appendDummyInput().appendField("Servo canal")
+ .appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
+ .appendField("→ angle").appendField(new Blockly.FieldNumber(90, 0, 180, 1), "angle").appendField("°");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwReadQR", function () {
+ this.appendDummyInput().appendField("QR — attendu").appendField(new Blockly.FieldTextInput("ZAC-A1"), "expected");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwLEDPattern", function () {
+ this.appendDummyInput().appendField("LED motif").appendField(dropdown([
+ ["arc-en-ciel","rainbow"],["clignote","blink"],["fondu","fade"],["éteindre","off"]
+ ]), "pattern");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwBuzzerTone", function () {
+ this.appendDummyInput().appendField("Buzzer")
+ .appendField(new Blockly.FieldNumber(2000, 20, 20000, 1), "freq").appendField("Hz pendant")
+ .appendField(new Blockly.FieldNumber(120, 1, 10000, 1), "ms").appendField("ms");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwRelay", function () {
+ this.appendDummyInput().appendField("Relais canal")
+ .appendField(new Blockly.FieldNumber(0, 0, 15, 1), "channel")
+ .appendField(dropdown([["allumer","on"],["éteindre","off"],["impulsion","pulse"]]), "state");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwSensorRead", function () {
+ this.appendDummyInput().appendField("Lire capteur").appendField(new Blockly.FieldTextInput("A0"), "pin")
+ .appendField("→").appendField(new Blockly.FieldTextInput("lecture"), "var");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+def("zacus_hwButtonWait", function () {
+ this.appendDummyInput().appendField("Attendre bouton").appendField(new Blockly.FieldTextInput("btn_main"), "button")
+ .appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.hardware); this.setInputsInline(true);
+});
+
+// ESP-NOW
+def("zacus_espnowRegisterPeer", function () {
+ this.appendDummyInput().appendField("ESP-NOW alias").appendField(new Blockly.FieldTextInput("annexe1"), "alias")
+ .appendField("MAC").appendField(new Blockly.FieldTextInput("AA:BB:CC:DD:EE:FF"), "mac");
+ stack(this, COLOR.espnow); this.setInputsInline(true);
+});
+def("zacus_espnowSend", function () {
+ this.appendDummyInput().appendField("ESP-NOW → peer").appendField(new Blockly.FieldTextInput("annexe1"), "peer")
+ .appendField("cmd").appendField(new Blockly.FieldTextInput("open_door"), "command");
+ stack(this, COLOR.espnow); this.setInputsInline(true);
+});
+def("zacus_espnowBroadcast", function () {
+ this.appendDummyInput().appendField("ESP-NOW broadcast").appendField(new Blockly.FieldTextInput("reset"), "command");
+ stack(this, COLOR.espnow); this.setInputsInline(true);
+});
+def("zacus_espnowWait", function () {
+ this.appendDummyInput().appendField("ESP-NOW attendre").appendField(new Blockly.FieldTextInput("ready"), "command")
+ .appendField("timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.espnow); this.setInputsInline(true);
+});
+
+// ESP32-S3-BOX-3
+def("zacus_boxIMUShake", function () {
+ this.appendDummyInput().appendField("BOX-3 secousse seuil")
+ .appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
+ .appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.box3); this.setInputsInline(true);
+});
+def("zacus_boxIRSend", function () {
+ this.appendDummyInput().appendField("BOX-3 IR").appendField(dropdown(["NEC","RC5","SONY","RAW"]), "protocol")
+ .appendField(new Blockly.FieldTextInput("0x20DF10EF"), "code");
+ stack(this, COLOR.box3); this.setInputsInline(true);
+});
+
+// M5Stack / M5StickC
+def("zacus_m5Beep", function () {
+ this.appendDummyInput().appendField("M5 bip")
+ .appendField(new Blockly.FieldNumber(4000, 20, 20000, 1), "freq").appendField("Hz")
+ .appendField(new Blockly.FieldNumber(200, 1, 5000, 1), "ms").appendField("ms");
+ stack(this, COLOR.m5); this.setInputsInline(true);
+});
+def("zacus_m5LCDText", function () {
+ this.appendDummyInput().appendField("M5 LCD").appendField(new Blockly.FieldTextInput("Hello"), "text")
+ .appendField(dropdown(["white","yellow","red","green","blue","cyan","magenta"]), "color")
+ .appendField("size").appendField(new Blockly.FieldNumber(2, 1, 8, 1), "size");
+ stack(this, COLOR.m5); this.setInputsInline(true);
+});
+def("zacus_m5ButtonAB", function () {
+ this.appendDummyInput().appendField("M5 bouton").appendField(dropdown(["A","B","C","any"]), "button")
+ .appendField("timeout").appendField(new Blockly.FieldNumber(0, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.m5); this.setInputsInline(true);
+});
+def("zacus_m5RGBLed", function () {
+ this.appendDummyInput().appendField("M5 LED RGB").appendField(new Blockly.FieldTextInput("#FF8800"), "color");
+ stack(this, COLOR.m5); this.setInputsInline(true);
+});
+def("zacus_m5IMUShake", function () {
+ this.appendDummyInput().appendField("M5 secousse seuil")
+ .appendField(new Blockly.FieldNumber(1.5, 0.1, 10, 0.1), "threshold")
+ .appendField("g timeout").appendField(new Blockly.FieldNumber(10, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.m5); this.setInputsInline(true);
+});
+
+// PLIP téléphone
+def("zacus_plipRing", function () {
+ this.appendDummyInput().appendField("☎ PLIP sonner")
+ .appendField(new Blockly.FieldNumber(3, 0, 60, 0.1), "duration_s").appendField("s");
+ stack(this, COLOR.plip); this.setInputsInline(true);
+});
+def("zacus_plipPickupWait", function () {
+ this.appendDummyInput().appendField("☎ PLIP attendre décroché — timeout")
+ .appendField(new Blockly.FieldNumber(30, 0, 600, 1), "timeout_s").appendField("s");
+ stack(this, COLOR.plip); this.setInputsInline(true);
+});
+
+// Logique
+def("zacus_logicIf", function () {
+ this.appendDummyInput().appendField("si").appendField(new Blockly.FieldTextInput("score > 0"), "condition");
+ this.appendStatementInput("body").setCheck(null).appendField("alors");
+ this.appendStatementInput("else").setCheck(null).appendField("sinon");
+ stack(this, COLOR.logic);
+});
+def("zacus_logicTimer", function () {
+ this.appendDummyInput().appendField("Attendre")
+ .appendField(new Blockly.FieldNumber(5, 0, 600, 0.1), "seconds").appendField("s");
+ stack(this, COLOR.logic); this.setInputsInline(true);
+});
+def("zacus_logicScore", function () {
+ this.appendDummyInput().appendField("Score +")
+ .appendField(new Blockly.FieldNumber(1, -100, 100, 1), "delta");
+ stack(this, COLOR.logic); this.setInputsInline(true);
+});
+def("zacus_logicSetVar", function () {
+ this.appendDummyInput().appendField("Variable").appendField(new Blockly.FieldTextInput("key"), "name")
+ .appendField(":=").appendField(new Blockly.FieldTextInput("42"), "value");
+ stack(this, COLOR.logic); this.setInputsInline(true);
+});
+
+window.ZACUS_BLOCK_TYPES = [
+ "zacus_sceneStart","zacus_sceneEnd","zacus_sceneGoto","zacus_sceneBranch",
+ "zacus_npcSay","zacus_npcWaitResponse","zacus_npcIntentMatch",
+ "zacus_hwSoundPlay","zacus_hwAudioStop","zacus_hwAudioVolume",
+ "zacus_hwLCDText","zacus_hwLCDClear","zacus_hwLCDImage","zacus_hwLCDTouchWait",
+ "zacus_hwServo","zacus_hwReadQR","zacus_hwLEDPattern","zacus_hwBuzzerTone","zacus_hwRelay","zacus_hwSensorRead","zacus_hwButtonWait",
+ "zacus_espnowRegisterPeer","zacus_espnowSend","zacus_espnowBroadcast","zacus_espnowWait",
+ "zacus_boxIMUShake","zacus_boxIRSend",
+ "zacus_m5Beep","zacus_m5LCDText","zacus_m5ButtonAB","zacus_m5RGBLed","zacus_m5IMUShake",
+ "zacus_plipRing","zacus_plipPickupWait",
+ "zacus_logicIf","zacus_logicTimer","zacus_logicScore","zacus_logicSetVar",
+];
+
+window.ZACUS_FIELDS_BY_KIND = {
+ zacus_sceneStart: ["id"],
+ zacus_sceneEnd: [],
+ zacus_sceneGoto: ["target"],
+ zacus_sceneBranch: ["condition","ifTrue","ifFalse"],
+ zacus_npcSay: ["text"],
+ zacus_npcWaitResponse: ["timeout_s"],
+ zacus_npcIntentMatch: ["intent","then"],
+ zacus_hwSoundPlay: ["asset"],
+ zacus_hwAudioStop: [],
+ zacus_hwAudioVolume: ["level"],
+ zacus_hwLCDText: ["line","text"],
+ zacus_hwLCDClear: [],
+ zacus_hwLCDImage: ["asset"],
+ zacus_hwLCDTouchWait: ["zone","timeout_s"],
+ zacus_hwServo: ["channel","angle"],
+ zacus_hwReadQR: ["expected"],
+ zacus_hwLEDPattern: ["pattern"],
+ zacus_hwBuzzerTone: ["freq","ms"],
+ zacus_hwRelay: ["channel","state"],
+ zacus_hwSensorRead: ["pin","var"],
+ zacus_hwButtonWait: ["button","timeout_s"],
+ zacus_espnowRegisterPeer: ["alias","mac"],
+ zacus_espnowSend: ["peer","command"],
+ zacus_espnowBroadcast: ["command"],
+ zacus_espnowWait: ["command","timeout_s"],
+ zacus_boxIMUShake: ["threshold","timeout_s"],
+ zacus_boxIRSend: ["protocol","code"],
+ zacus_m5Beep: ["freq","ms"],
+ zacus_m5LCDText: ["text","color","size"],
+ zacus_m5ButtonAB: ["button","timeout_s"],
+ zacus_m5RGBLed: ["color"],
+ zacus_m5IMUShake: ["threshold","timeout_s"],
+ zacus_plipRing: ["duration_s"],
+ zacus_plipPickupWait: ["timeout_s"],
+ zacus_logicIf: ["condition"],
+ zacus_logicTimer: ["seconds"],
+ zacus_logicScore: ["delta"],
+ zacus_logicSetVar: ["name","value"],
+};
diff --git a/apps/zacus-hub/Resources/blockly/zacus_codec.js b/apps/zacus-hub/Resources/blockly/zacus_codec.js
new file mode 100644
index 0000000..6d46d9d
--- /dev/null
+++ b/apps/zacus-hub/Resources/blockly/zacus_codec.js
@@ -0,0 +1,327 @@
+// Codec for the Blockly workspace:
+// - workspace ↔ blocks_studio_version: 2 YAML (gateway round-trip)
+// - workspace ↔ Scratch 3 .sb3 archive (procedures_call encoding)
+//
+// .sb3 strategy: every Zacus block becomes a Scratch `procedures_call`
+// whose `proccode` is `zacus. ...`. This keeps the
+// file structurally valid Scratch (opens in scratch.mit.edu without error)
+// while losslessly preserving our 15 domain block kinds.
+
+(function () {
+
+ function uuid() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c/4).toString(16));
+ }
+
+ // ---------- YAML helpers ----------
+
+ function yamlScalar(v) {
+ if (v == null) return "\"\"";
+ const s = String(v);
+ if (s === "") return "\"\"";
+ if (s.indexOf("\n") >= 0) {
+ const lines = s.split("\n");
+ return "|\n" + lines.map(l => " " + l).join("\n");
+ }
+ // Always quote — keeps round-trip safe (hex like 0xABCD, leading zeros,
+ // colons, anything yaml might reinterpret as a typed scalar).
+ return "\"" + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + "\"";
+ }
+
+ function workspaceToYaml(workspace) {
+ const allBlocks = workspace.getAllBlocks(false).filter(b => b.type.startsWith("zacus_"));
+ let out = "blocks_studio_version: 2\nnodes:\n";
+ if (allBlocks.length === 0) { out += " []\n"; return out; }
+ for (const b of allBlocks) {
+ const kind = b.type.replace(/^zacus_/, "");
+ const xy = b.getRelativeToSurfaceXY();
+ out += ` - id: "${b.id}"\n`;
+ out += ` kind: ${kind}\n`;
+ out += ` position: [${Math.round(xy.x)}, ${Math.round(xy.y)}]\n`;
+ const nextBlock = b.getNextBlock();
+ if (nextBlock && nextBlock.type.startsWith("zacus_")) {
+ out += ` next: "${nextBlock.id}"\n`;
+ }
+ const fields = (window.ZACUS_FIELDS_BY_KIND[b.type] || []);
+ if (fields.length) {
+ out += " params:\n";
+ for (const f of fields) {
+ const value = b.getFieldValue(f) ?? "";
+ out += ` ${f}: ${yamlScalar(value)}\n`;
+ }
+ }
+ // Slots (statement inputs) — for logicIf: body / else
+ const slotInputs = b.inputList.filter(i => i.type === Blockly.inputs.inputTypes.STATEMENT);
+ const slotMap = {};
+ for (const input of slotInputs) {
+ const head = input.connection && input.connection.targetBlock();
+ if (head && head.type.startsWith("zacus_")) slotMap[input.name] = head.id;
+ }
+ if (Object.keys(slotMap).length) {
+ out += " slots:\n";
+ for (const k of Object.keys(slotMap).sort()) {
+ out += ` ${k}: "${slotMap[k]}"\n`;
+ }
+ }
+ }
+ return out;
+ }
+
+ // ---------- YAML → Workspace XML ----------
+
+ function yamlToWorkspaceXml(yamlText) {
+ if (!yamlText || !yamlText.includes("blocks_studio_version")) return null;
+ // We don't pull a full YAML parser — we tolerate v2 only with simple structure.
+ const nodes = parseV2Nodes(yamlText);
+ if (!nodes.length) return null;
+ const byId = {}; for (const n of nodes) byId[n.id] = n;
+ const referenced = new Set();
+ for (const n of nodes) {
+ if (n.next) referenced.add(n.next);
+ for (const k in (n.slots || {})) referenced.add(n.slots[k]);
+ }
+ const roots = nodes.filter(n => !referenced.has(n.id));
+
+ function blockXml(n) {
+ const lines = [];
+ lines.push(``);
+ const fields = window.ZACUS_FIELDS_BY_KIND["zacus_"+n.kind] || [];
+ for (const f of fields) {
+ const v = (n.params || {})[f];
+ if (v !== undefined) lines.push(`${escapeXml(v)}`);
+ }
+ for (const slotName of Object.keys(n.slots || {})) {
+ const headId = n.slots[slotName];
+ const head = byId[headId];
+ if (head) {
+ lines.push(`${chainXml(head)}`);
+ }
+ }
+ const nxt = n.next ? byId[n.next] : null;
+ if (nxt) lines.push(`${blockXml(nxt)}`);
+ lines.push(``);
+ return lines.join("");
+ }
+ function chainXml(head) { return blockXml(head); }
+
+ const xml = `` +
+ roots.map(blockXml).join("") + ``;
+ return xml;
+ }
+
+ function parseV2Nodes(text) {
+ const lines = text.split("\n");
+ const nodes = [];
+ let cur = null;
+ let mode = null; // "params" | "slots" | null
+ for (let raw of lines) {
+ const line = raw.replace(/\r$/, "");
+ const trimmed = line.trim();
+ if (trimmed.startsWith("- id:")) {
+ if (cur) nodes.push(cur);
+ cur = { id: stripQuotes(trimmed.slice(5).trim()), kind: "", position: [0,0], params: {}, slots: {}, next: null };
+ mode = null;
+ } else if (cur) {
+ if (trimmed.startsWith("kind:")) { cur.kind = trimmed.slice(5).trim(); mode = null; }
+ else if (trimmed.startsWith("position:")) {
+ const nums = trimmed.slice(9).trim().replace(/[\[\]]/g, "").split(",").map(s=>parseFloat(s.trim()));
+ if (nums.length === 2) cur.position = nums;
+ mode = null;
+ }
+ else if (trimmed.startsWith("next:")) { cur.next = stripQuotes(trimmed.slice(5).trim()); mode = null; }
+ else if (trimmed.startsWith("params:")) { mode = "params"; }
+ else if (trimmed.startsWith("slots:")) { mode = "slots"; }
+ else if (mode && trimmed.includes(":")) {
+ const idx = trimmed.indexOf(":");
+ const key = trimmed.slice(0, idx).trim();
+ const val = stripQuotes(trimmed.slice(idx+1).trim());
+ if (key) {
+ if (mode === "params") cur.params[key] = val;
+ else if (mode === "slots") cur.slots[key] = val;
+ }
+ }
+ }
+ }
+ if (cur) nodes.push(cur);
+ return nodes;
+ }
+
+ function stripQuotes(s) {
+ if (s.length >= 2 && ((s[0]==='"' && s[s.length-1]==='"') || (s[0]==="'" && s[s.length-1]==="'"))) {
+ return s.slice(1, -1);
+ }
+ return s;
+ }
+
+ function escapeXml(s) {
+ return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'");
+ }
+
+ // ---------- .sb3 export ----------
+
+ // Minimal valid Scratch 3 backdrop: a 1×1 transparent PNG.
+ const BACKDROP_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAfbLI3wAAAABJRU5ErkJggg==";
+ const BACKDROP_MD5 = "cd21514d0531fdffb22204e0ec5ed84a"; // md5 of the PNG bytes above
+ // (md5 is required by Scratch's asset references — using a well-known empty
+ // backdrop md5; if Scratch refuses, we fall back to recomputing.)
+
+ async function workspaceToSB3(workspace) {
+ const yaml = workspaceToYaml(workspace);
+ const allBlocks = workspace.getAllBlocks(false).filter(b => b.type.startsWith("zacus_"));
+
+ const sbBlocks = {};
+ for (const b of allBlocks) {
+ const kind = b.type.replace(/^zacus_/, "");
+ const fields = (window.ZACUS_FIELDS_BY_KIND[b.type] || []);
+ const proccode = "zacus." + kind + (fields.length ? " " + fields.map(_ => "%s").join(" ") : "");
+ const argNames = fields;
+ const argIds = fields.map(f => `arg_${b.id}_${f}`);
+ const argDefaults = fields.map(f => b.getFieldValue(f) ?? "");
+
+ const callId = b.id;
+ const inputs = {};
+ const callArgs = {};
+ for (let i = 0; i < fields.length; i++) {
+ const aid = `lit_${b.id}_${fields[i]}`;
+ // shadow text literal block
+ sbBlocks[aid] = {
+ opcode: "text",
+ next: null, parent: callId,
+ inputs: {}, fields: { TEXT: [String(argDefaults[i]), null] },
+ shadow: true, topLevel: false,
+ };
+ callArgs[argIds[i]] = [1, aid];
+ }
+
+ const parent = b.getParent();
+ const nextBlock = b.getNextBlock();
+ sbBlocks[callId] = {
+ opcode: "procedures_call",
+ next: nextBlock ? nextBlock.id : null,
+ parent: parent ? parent.id : null,
+ inputs: callArgs,
+ fields: {},
+ shadow: false,
+ topLevel: !parent,
+ x: parent ? undefined : Math.round(b.getRelativeToSurfaceXY().x),
+ y: parent ? undefined : Math.round(b.getRelativeToSurfaceXY().y),
+ mutation: {
+ tagName: "mutation",
+ children: [],
+ proccode: proccode,
+ argumentids: JSON.stringify(argIds),
+ warp: "false",
+ },
+ };
+ // Slot bodies are not expanded into Scratch's stack here — slots stay
+ // available as round-trip via the embedded YAML.
+ }
+
+ const stage = {
+ isStage: true,
+ name: "Stage",
+ variables: {},
+ lists: {},
+ broadcasts: {},
+ blocks: sbBlocks,
+ comments: {
+ "zacus_payload": {
+ blockId: null, x: 20, y: 20, width: 420, height: 320,
+ minimized: false,
+ text: "ZACUS_BLOCKS_YAML_BEGIN\n" + yaml + "\nZACUS_BLOCKS_YAML_END",
+ }
+ },
+ currentCostume: 0,
+ costumes: [{
+ name: "backdrop1",
+ bitmapResolution: 1,
+ dataFormat: "png",
+ assetId: BACKDROP_MD5,
+ md5ext: BACKDROP_MD5 + ".png",
+ rotationCenterX: 0,
+ rotationCenterY: 0,
+ }],
+ sounds: [],
+ volume: 100,
+ layerOrder: 0,
+ tempo: 60,
+ videoTransparency: 50,
+ videoState: "on",
+ textToSpeechLanguage: null,
+ };
+ const project = {
+ targets: [stage],
+ monitors: [],
+ extensions: [],
+ meta: { semver: "3.0.0", vm: "2.3.4", agent: "ZacusHub/0.1" }
+ };
+ const zip = new JSZip();
+ zip.file("project.json", JSON.stringify(project));
+ // backdrop asset
+ const bytes = Uint8Array.from(atob(BACKDROP_PNG_B64), c => c.charCodeAt(0));
+ zip.file(BACKDROP_MD5 + ".png", bytes);
+ return zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+ }
+
+ // ---------- .sb3 import ----------
+
+ async function sb3ToWorkspaceXml(arrayBuffer) {
+ const zip = await JSZip.loadAsync(arrayBuffer);
+ const projFile = zip.file("project.json");
+ if (!projFile) throw new Error("project.json missing");
+ const project = JSON.parse(await projFile.async("string"));
+ // Prefer round-trip via the embedded comment, if present
+ for (const t of (project.targets || [])) {
+ for (const c of Object.values(t.comments || {})) {
+ const m = /ZACUS_BLOCKS_YAML_BEGIN\n([\s\S]*?)\nZACUS_BLOCKS_YAML_END/.exec(c.text || "");
+ if (m) return yamlToWorkspaceXml(m[1]);
+ }
+ }
+ // Fallback: parse procedures_call entries with proccode zacus. ...
+ const nodes = [];
+ for (const t of (project.targets || [])) {
+ const blocks = t.blocks || {};
+ const callIds = Object.keys(blocks).filter(k => blocks[k].opcode === "procedures_call"
+ && blocks[k].mutation && /^zacus\./.test(blocks[k].mutation.proccode || ""));
+ for (const id of callIds) {
+ const b = blocks[id];
+ const proccode = b.mutation.proccode;
+ const kind = proccode.split(" ")[0].slice("zacus.".length);
+ const argIds = JSON.parse(b.mutation.argumentids || "[]");
+ const params = {};
+ const fieldNames = (window.ZACUS_FIELDS_BY_KIND["zacus_"+kind] || []);
+ for (let i = 0; i < argIds.length && i < fieldNames.length; i++) {
+ const input = (b.inputs || {})[argIds[i]];
+ if (input && input[1]) {
+ const litId = input[1];
+ const lit = blocks[litId];
+ const text = lit && lit.fields && lit.fields.TEXT && lit.fields.TEXT[0];
+ if (text !== undefined) params[fieldNames[i]] = text;
+ }
+ }
+ nodes.push({ id: id, kind: kind, position: [b.x ?? 60, b.y ?? 60], next: b.next, params: params, slots: {} });
+ }
+ }
+ // Synthesize a v2 YAML and reuse the YAML→XML path
+ let yaml = "blocks_studio_version: 2\nnodes:\n";
+ for (const n of nodes) {
+ yaml += ` - id: "${n.id}"\n kind: ${n.kind}\n position: [${n.position[0]}, ${n.position[1]}]\n`;
+ if (n.next) yaml += ` next: "${n.next}"\n`;
+ if (Object.keys(n.params).length) {
+ yaml += " params:\n";
+ for (const k of Object.keys(n.params).sort()) {
+ yaml += ` ${k}: ${yamlScalar(n.params[k])}\n`;
+ }
+ }
+ }
+ return yamlToWorkspaceXml(yaml);
+ }
+
+ window.ZacusCodec = {
+ workspaceToYaml,
+ yamlToWorkspaceXml,
+ workspaceToSB3,
+ sb3ToWorkspaceXml,
+ };
+})();
diff --git a/apps/zacus-hub/Sources/App/RootView.swift b/apps/zacus-hub/Sources/App/RootView.swift
new file mode 100644
index 0000000..913e276
--- /dev/null
+++ b/apps/zacus-hub/Sources/App/RootView.swift
@@ -0,0 +1,69 @@
+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) { self.init(get: { source.wrappedValue }, set: { source.wrappedValue = $0 }) }
+}
diff --git a/apps/zacus-hub/Sources/App/SettingsView.swift b/apps/zacus-hub/Sources/App/SettingsView.swift
new file mode 100644
index 0000000..84aac98
--- /dev/null
+++ b/apps/zacus-hub/Sources/App/SettingsView.swift
@@ -0,0 +1,72 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @EnvironmentObject var session: HubSession
+ @Environment(\.dismiss) private var dismiss
+ @State private var baseURL: String = ""
+ @State private var token: String = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Gateway") {
+ TextField("URL de base", text: $baseURL)
+ .textContentType(.URL)
+ .autocorrectionDisabled()
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.URL)
+ #endif
+ SecureField("Token bearer", text: $token)
+ .textContentType(.password)
+ }
+
+ Section("État") {
+ Label(authLabel, systemImage: authIcon).foregroundStyle(authColor)
+ if let err = session.lastError { Text(err).font(.caption).foregroundStyle(.secondary) }
+ }
+ }
+ .navigationTitle("Réglages")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Annuler") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Enregistrer") {
+ session.updateConfig(HubConfig(baseURL: baseURL, token: token))
+ dismiss()
+ }
+ }
+ }
+ }
+ .onAppear {
+ baseURL = session.config.baseURL
+ token = session.config.token
+ }
+ #if os(macOS)
+ .frame(minWidth: 480, minHeight: 320)
+ #endif
+ }
+
+ private var authLabel: String {
+ switch session.authStatus {
+ case .unknown: return "Non vérifié"
+ case .ok: return "Authentifié"
+ case .failed: return "Échec d'authentification"
+ }
+ }
+ private var authIcon: String {
+ switch session.authStatus {
+ case .unknown: return "questionmark.circle"
+ case .ok: return "checkmark.circle.fill"
+ case .failed: return "exclamationmark.triangle.fill"
+ }
+ }
+ private var authColor: Color {
+ switch session.authStatus {
+ case .unknown: return .secondary
+ case .ok: return .green
+ case .failed: return .red
+ }
+ }
+}
diff --git a/apps/zacus-hub/Sources/App/ZacusHubApp.swift b/apps/zacus-hub/Sources/App/ZacusHubApp.swift
new file mode 100644
index 0000000..0c890e1
--- /dev/null
+++ b/apps/zacus-hub/Sources/App/ZacusHubApp.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+@main
+struct ZacusHubApp: App {
+ @StateObject private var session = HubSession()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(session)
+ .task { await session.bootstrap() }
+ }
+ #if os(macOS)
+ .defaultSize(width: 1100, height: 720)
+ #endif
+ }
+}
diff --git a/apps/zacus-hub/Sources/Companion/CompanionView.swift b/apps/zacus-hub/Sources/Companion/CompanionView.swift
new file mode 100644
index 0000000..41db7ec
--- /dev/null
+++ b/apps/zacus-hub/Sources/Companion/CompanionView.swift
@@ -0,0 +1,218 @@
+import SwiftUI
+#if os(iOS)
+import UniformTypeIdentifiers
+#endif
+
+struct CompanionView: View {
+ @EnvironmentObject var session: HubSession
+ @StateObject private var recorder = AudioRecorder()
+
+ @State private var hint: String = ""
+ @State private var sceneId: String = "intro"
+ @State private var level: Int = 1
+ @State private var hintLoading = false
+
+ @State private var transcription: String = ""
+ @State private var transcribeLoading = false
+ @State private var pickerPresented = false
+ @State private var elapsed: TimeInterval = 0
+
+ @State private var error: String?
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ header
+ pushToTalkCard
+ hintCard
+ transcribeCard
+ if let error { Text(error).font(.caption).foregroundStyle(.red) }
+ }
+ .padding()
+ }
+ .navigationTitle("Companion")
+ #if os(iOS)
+ .fileImporter(
+ isPresented: $pickerPresented,
+ allowedContentTypes: [.audio, UTType(filenameExtension: "wav") ?? .data, UTType(filenameExtension: "m4a") ?? .data],
+ allowsMultipleSelection: false
+ ) { result in
+ Task { await handlePicked(result) }
+ }
+ #endif
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Companion").font(.largeTitle.bold())
+ Text("Push-to-talk, transcription, indices.").foregroundStyle(.secondary)
+ }
+ }
+
+ // MARK: Push-to-talk
+
+ private var pushToTalkCard: some View {
+ GroupBox("Push-to-talk") {
+ VStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(micFill)
+ .frame(width: 140, height: 140)
+ .shadow(radius: isRecording ? 12 : 4)
+ .scaleEffect(isRecording ? 1.06 : 1)
+ .animation(.easeInOut(duration: 0.25), value: isRecording)
+ Image(systemName: micIcon)
+ .font(.system(size: 56, weight: .bold))
+ .foregroundStyle(.white)
+ }
+ .gesture(
+ LongPressGesture(minimumDuration: 0.05)
+ .sequenced(before: DragGesture(minimumDistance: 0))
+ .onChanged { _ in if !isRecording { Task { await recorder.start() } } }
+ .onEnded { _ in Task { await finishRecording() } }
+ )
+ Text(statusText)
+ .font(.subheadline.monospacedDigit())
+ .foregroundStyle(.secondary)
+ Text("Maintiens pour parler, relâche pour transcrire.")
+ .font(.caption).foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ if !transcription.isEmpty {
+ Text(transcription)
+ .font(.body)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 4)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in tickElapsed() }
+ }
+ }
+
+ private var isRecording: Bool {
+ if case .recording = recorder.state { return true } else { return false }
+ }
+
+ private var micFill: Color {
+ switch recorder.state {
+ case .recording: return .red
+ case .stopping, .requestingPermission: return .orange
+ case .denied, .failed: return .gray
+ default: return transcribeLoading ? .orange : .accentColor
+ }
+ }
+
+ private var micIcon: String {
+ switch recorder.state {
+ case .recording: return "stop.circle.fill"
+ case .stopping, .requestingPermission: return "ellipsis.circle"
+ case .denied: return "mic.slash"
+ case .failed: return "exclamationmark.triangle"
+ default: return transcribeLoading ? "waveform" : "mic.fill"
+ }
+ }
+
+ private var statusText: String {
+ switch recorder.state {
+ case .idle: return transcribeLoading ? "Transcription…" : "Prêt"
+ case .requestingPermission: return "Autorisation micro…"
+ case .recording: return String(format: "● Enregistrement %.1f s", elapsed)
+ case .stopping: return "Finalisation…"
+ case .denied: return "Micro refusé — autorise dans Réglages."
+ case .failed(let m): return "Erreur: \(m)"
+ }
+ }
+
+ private func tickElapsed() {
+ if case .recording(let start) = recorder.state {
+ elapsed = Date().timeIntervalSince(start)
+ } else if elapsed != 0 {
+ elapsed = 0
+ }
+ }
+
+ private func finishRecording() async {
+ guard let url = await recorder.stop() else { return }
+ await transcribe(url: url)
+ }
+
+ // MARK: Hint
+
+ private var hintCard: some View {
+ GroupBox("Demander un indice") {
+ VStack(alignment: .leading, spacing: 12) {
+ TextField("Scène", text: $sceneId).textFieldStyle(.roundedBorder)
+ Stepper("Niveau \(level)", value: $level, in: 1...3)
+ Button {
+ Task { await askHint() }
+ } label: {
+ Label(hintLoading ? "…" : "Obtenir un indice", systemImage: "lightbulb")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .disabled(hintLoading || sceneId.isEmpty)
+ if !hint.isEmpty {
+ Text(hint).font(.body).padding(.top, 4)
+ }
+ }
+ }
+ }
+
+ // MARK: Transcribe from file
+
+ private var transcribeCard: some View {
+ GroupBox("Ou transcrire un fichier") {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Choisis un .wav/.m4a déjà enregistré.")
+ .font(.caption).foregroundStyle(.secondary)
+ Button {
+ #if os(iOS)
+ pickerPresented = true
+ #endif
+ } label: {
+ Label(transcribeLoading ? "…" : "Choisir un fichier", systemImage: "waveform.badge.magnifyingglass")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .disabled(transcribeLoading)
+ }
+ }
+ }
+
+ private func askHint() async {
+ hintLoading = true
+ defer { hintLoading = false }
+ do {
+ let resp = try await session.api.askHint(scene: sceneId, level: level)
+ hint = resp.hint ?? "(pas de réponse)"
+ error = nil
+ } catch let err { self.error = err.localizedDescription }
+ }
+
+ #if os(iOS)
+ private func handlePicked(_ result: Result<[URL], Error>) async {
+ switch result {
+ case .failure(let err):
+ error = err.localizedDescription
+ case .success(let urls):
+ guard let url = urls.first else { return }
+ await transcribe(url: url)
+ }
+ }
+ #endif
+
+ private func transcribe(url: URL) async {
+ transcribeLoading = true
+ defer { transcribeLoading = false }
+ let didStart = url.startAccessingSecurityScopedResource()
+ defer { if didStart { url.stopAccessingSecurityScopedResource() } }
+ do {
+ let data = try Data(contentsOf: url)
+ let mime = url.pathExtension.lowercased() == "m4a" ? "audio/mp4" : "audio/wav"
+ let resp = try await session.api.transcribe(audio: data, filename: url.lastPathComponent, mime: mime)
+ transcription = resp.text ?? resp.raw ?? "(pas de texte)"
+ error = nil
+ } catch let err { self.error = err.localizedDescription }
+ }
+}
diff --git a/apps/zacus-hub/Sources/GameMaster/GameMasterView.swift b/apps/zacus-hub/Sources/GameMaster/GameMasterView.swift
new file mode 100644
index 0000000..8560555
--- /dev/null
+++ b/apps/zacus-hub/Sources/GameMaster/GameMasterView.swift
@@ -0,0 +1,129 @@
+import SwiftUI
+
+struct GameMasterView: View {
+ @EnvironmentObject var session: HubSession
+ @State private var hintScene: String = "intro"
+ @State private var hintLevel: Int = 1
+ @State private var actionLog: [String] = []
+ @State private var sending = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ header
+ backendsSection
+ sceneSection
+ actionsSection
+ if !actionLog.isEmpty { logSection }
+ if let error = session.lastError { Text(error).font(.callout).foregroundStyle(.red) }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .navigationTitle("Game master")
+ }
+
+ private var header: some View {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Live").font(.caption.bold()).foregroundStyle(.secondary)
+ Text(session.streamActive ? "Connecté au gateway" : "Hors ligne")
+ .font(.title3.bold())
+ .foregroundStyle(session.streamActive ? .green : .orange)
+ }
+ Spacer()
+ Image(systemName: session.streamActive ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash")
+ .font(.title)
+ .foregroundStyle(session.streamActive ? .green : .orange)
+ }
+ }
+
+ private var backendsSection: some View {
+ GroupBox("Backends") {
+ VStack(alignment: .leading, spacing: 8) {
+ if session.state.backends.isEmpty {
+ Text("En attente de la première trame WS…").font(.caption).foregroundStyle(.secondary)
+ }
+ ForEach(session.state.backends) { backend in
+ HStack(spacing: 10) {
+ Circle().fill(backend.online ? Color.green : Color.red).frame(width: 10, height: 10)
+ Text(backend.name).font(.body.monospaced())
+ Spacer()
+ if let lat = backend.latency_ms {
+ Text("\(Int(lat)) ms").font(.caption).foregroundStyle(.secondary)
+ }
+ if let detail = backend.detail {
+ Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var sceneSection: some View {
+ GroupBox("Partie") {
+ Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
+ GridRow {
+ Text("Scène").font(.caption).foregroundStyle(.secondary)
+ Text(session.state.scene_id ?? "—").font(.body.monospaced())
+ }
+ GridRow {
+ Text("Index").font(.caption).foregroundStyle(.secondary)
+ Text("\(session.state.scene_index)").font(.body.monospaced())
+ }
+ GridRow {
+ Text("Voice session").font(.caption).foregroundStyle(.secondary)
+ Text(session.state.voice_session ?? "—").font(.body.monospaced())
+ }
+ GridRow {
+ Text("Dernier indice").font(.caption).foregroundStyle(.secondary)
+ Text(session.state.last_hint ?? "—").lineLimit(3)
+ }
+ }
+ }
+ }
+
+ private var actionsSection: some View {
+ GroupBox("Déclencher un indice") {
+ VStack(alignment: .leading, spacing: 12) {
+ TextField("Scène", text: $hintScene).textFieldStyle(.roundedBorder)
+ Stepper("Niveau \(hintLevel)", value: $hintLevel, in: 1...3)
+ Button {
+ Task { await triggerHint() }
+ } label: {
+ Label(sending ? "Envoi…" : "Envoyer au moteur d'indices", systemImage: "lightbulb.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(sending || hintScene.isEmpty)
+ }
+ }
+ }
+
+ private var logSection: some View {
+ GroupBox("Journal") {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(actionLog.enumerated()), id: \.offset) { _, entry in
+ Text(entry).font(.caption.monospaced()).foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private func triggerHint() async {
+ sending = true
+ defer { sending = false }
+ do {
+ let resp = try await session.api.triggerGMHint(scene: hintScene, level: hintLevel)
+ actionLog.insert("[\(timestamp())] hint L\(hintLevel) → \(resp.hint ?? "(vide)")", at: 0)
+ } catch {
+ actionLog.insert("[\(timestamp())] erreur: \(error.localizedDescription)", at: 0)
+ }
+ }
+
+ private func timestamp() -> String {
+ let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: Date())
+ }
+}
diff --git a/apps/zacus-hub/Sources/Shared/AudioRecorder.swift b/apps/zacus-hub/Sources/Shared/AudioRecorder.swift
new file mode 100644
index 0000000..4516227
--- /dev/null
+++ b/apps/zacus-hub/Sources/Shared/AudioRecorder.swift
@@ -0,0 +1,112 @@
+import Foundation
+import AVFoundation
+
+@MainActor
+final class AudioRecorder: NSObject, ObservableObject, AVAudioRecorderDelegate {
+ enum State: Equatable {
+ case idle
+ case requestingPermission
+ case recording(startedAt: Date)
+ case stopping
+ case denied
+ case failed(String)
+ }
+
+ @Published private(set) var state: State = .idle
+ @Published private(set) var lastClipURL: URL?
+
+ private var recorder: AVAudioRecorder?
+
+ func start() async {
+ guard case .idle = state else { return }
+ state = .requestingPermission
+ let granted = await Self.requestPermission()
+ guard granted else {
+ state = .denied
+ return
+ }
+ do {
+ try Self.configureSession()
+ let url = Self.makeClipURL()
+ let settings: [String: Any] = [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 16_000.0,
+ AVNumberOfChannelsKey: 1,
+ AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue
+ ]
+ let rec = try AVAudioRecorder(url: url, settings: settings)
+ rec.delegate = self
+ guard rec.record() else {
+ state = .failed("AVAudioRecorder.record() returned false")
+ return
+ }
+ recorder = rec
+ lastClipURL = url
+ state = .recording(startedAt: Date())
+ } catch {
+ state = .failed(error.localizedDescription)
+ }
+ }
+
+ /// Stops the recorder and returns the captured clip URL when ready.
+ func stop() async -> URL? {
+ guard case .recording = state, let rec = recorder else {
+ state = .idle
+ return nil
+ }
+ state = .stopping
+ rec.stop()
+ // give the encoder a tick to flush
+ try? await Task.sleep(nanoseconds: 120_000_000)
+ let url = rec.url
+ recorder = nil
+ state = .idle
+ Self.deactivateSession()
+ return url
+ }
+
+ func reset() {
+ recorder?.stop()
+ recorder = nil
+ state = .idle
+ Self.deactivateSession()
+ }
+
+ // MARK: - helpers
+
+ private static func makeClipURL() -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ let name = "ptt-\(Int(Date().timeIntervalSince1970*1000)).m4a"
+ return dir.appendingPathComponent(name)
+ }
+
+ private static func requestPermission() async -> Bool {
+ #if os(iOS)
+ if #available(iOS 17, *) {
+ return await AVAudioApplication.requestRecordPermission()
+ } else {
+ return await withCheckedContinuation { cont in
+ AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
+ }
+ }
+ #else
+ return await withCheckedContinuation { cont in
+ AVCaptureDevice.requestAccess(for: .audio) { cont.resume(returning: $0) }
+ }
+ #endif
+ }
+
+ private static func configureSession() throws {
+ #if os(iOS)
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth])
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
+ #endif
+ }
+
+ private static func deactivateSession() {
+ #if os(iOS)
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ #endif
+ }
+}
diff --git a/apps/zacus-hub/Sources/Shared/HubAPI.swift b/apps/zacus-hub/Sources/Shared/HubAPI.swift
new file mode 100644
index 0000000..5acaac8
--- /dev/null
+++ b/apps/zacus-hub/Sources/Shared/HubAPI.swift
@@ -0,0 +1,244 @@
+import Foundation
+
+enum HubError: Error, LocalizedError {
+ case badURL
+ case http(Int, String)
+ case decode(Error)
+ case transport(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .badURL: return "Configuration: URL invalide."
+ case .http(let code, let body): return "HTTP \(code): \(body)"
+ case .decode(let err): return "Decode: \(err.localizedDescription)"
+ case .transport(let err): return "Réseau: \(err.localizedDescription)"
+ }
+ }
+}
+
+actor HubAPI {
+ private var config: HubConfig
+ private let session: URLSession
+
+ init(config: HubConfig) {
+ self.config = config
+ let cfg = URLSessionConfiguration.default
+ cfg.timeoutIntervalForRequest = 20
+ cfg.waitsForConnectivity = true
+ self.session = URLSession(configuration: cfg)
+ }
+
+ func update(config: HubConfig) { self.config = config }
+ func currentConfig() -> HubConfig { config }
+
+ // MARK: - State + health
+
+ func ping() async throws { _ = try await get("/v1/auth/ping") as PingResponse }
+ func fetchState() async throws -> GameState { try await get("/v1/state") }
+ func fetchHealth() async throws -> [BackendHealth] { try await get("/v1/backends/health") }
+
+ // MARK: - Game master
+
+ func triggerGMHint(scene: String, level: Int) async throws -> HintResponse {
+ try await post("/v1/gm/hint", body: ["scene": scene, "level": level])
+ }
+
+ // MARK: - Companion
+
+ func askHint(scene: String, level: Int) async throws -> HintResponse {
+ try await post("/v1/companion/hint", body: ["scene": scene, "level": level])
+ }
+
+ func transcribe(audio: Data, filename: String, mime: String) async throws -> TranscriptionResponse {
+ let boundary = "Boundary-\(UUID().uuidString)"
+ var req = try makeRequest("/v1/companion/voice/transcribe", method: "POST")
+ req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ var body = Data()
+ body.appendString("--\(boundary)\r\n")
+ body.appendString("Content-Disposition: form-data; name=\"audio\"; filename=\"\(filename)\"\r\n")
+ body.appendString("Content-Type: \(mime)\r\n\r\n")
+ body.append(audio)
+ body.appendString("\r\n--\(boundary)--\r\n")
+ req.httpBody = body
+ return try await run(req)
+ }
+
+ // MARK: - Studio
+
+ func listScenarios() async throws -> [ScenarioMeta] {
+ try await get("/v1/studio/scenarios")
+ }
+
+ func loadScenario(name: String) async throws -> ScenarioDetail {
+ try await get("/v1/studio/scenario/\(name)")
+ }
+
+ func saveScenario(name: String, yaml: String) async throws -> ValidationResult {
+ var req = try makeRequest("/v1/studio/scenario/\(name)", method: "PUT")
+ req.httpBody = try JSONSerialization.data(withJSONObject: ["yaml": yaml])
+ return try await run(req)
+ }
+
+ func compileScenario(name: String) async throws -> CompileResult {
+ var req = try makeRequest("/v1/studio/scenario/\(name)/compile", method: "POST")
+ req.httpBody = Data("{}".utf8)
+ return try await run(req)
+ }
+
+ func listBoards() async throws -> [BoardInfo] {
+ try await get("/v1/flash/boards")
+ }
+
+ func flashBoard(_ board: String, scenario: String, strategy: String) async throws -> FlashResult {
+ try await post("/v1/flash/\(board)", body: ["scenario": scenario, "strategy": strategy])
+ }
+
+ func validateScenario(name: String, yaml: String? = nil) async throws -> ValidationResult {
+ if let yaml {
+ return try await post("/v1/studio/scenario/\(name)/validate", body: ["yaml": yaml])
+ }
+ var req = try makeRequest("/v1/studio/scenario/\(name)/validate", method: "POST")
+ req.httpBody = Data("{}".utf8)
+ return try await run(req)
+ }
+
+ // MARK: - WebSocket
+
+ func stateWebSocketURL() throws -> URL {
+ guard var components = URLComponents(string: config.baseURL) else { throw HubError.badURL }
+ let scheme = (components.scheme ?? "http") == "https" ? "wss" : "ws"
+ components.scheme = scheme
+ components.path = (components.path.isEmpty ? "" : components.path) + "/v1/state/ws"
+ components.queryItems = [URLQueryItem(name: "token", value: config.token)]
+ guard let url = components.url else { throw HubError.badURL }
+ return url
+ }
+
+ func openStateSocket() throws -> URLSessionWebSocketTask {
+ let url = try stateWebSocketURL()
+ let task = session.webSocketTask(with: url)
+ task.resume()
+ return task
+ }
+
+ // MARK: - helpers
+
+ private func makeRequest(_ path: String, method: String) throws -> URLRequest {
+ guard var components = URLComponents(string: config.baseURL) else { throw HubError.badURL }
+ components.path = (components.path.isEmpty ? "" : components.path) + path
+ guard let url = components.url else { throw HubError.badURL }
+ var req = URLRequest(url: url)
+ req.httpMethod = method
+ req.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ return req
+ }
+
+ private func get(_ path: String) async throws -> T {
+ try await run(makeRequest(path, method: "GET"))
+ }
+
+ private func post(_ path: String, body: [String: Any]) async throws -> T {
+ var req = try makeRequest(path, method: "POST")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+ return try await run(req)
+ }
+
+ private func run(_ req: URLRequest) async throws -> T {
+ let (data, resp): (Data, URLResponse)
+ do { (data, resp) = try await session.data(for: req) }
+ catch { throw HubError.transport(error) }
+ guard let http = resp as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? -1
+ let body = String(data: data, encoding: .utf8) ?? ""
+ throw HubError.http(code, body)
+ }
+ do { return try JSONDecoder().decode(T.self, from: data) }
+ catch { throw HubError.decode(error) }
+ }
+}
+
+// MARK: - DTOs
+
+struct PingResponse: Decodable { let status: String }
+
+struct BackendHealth: Codable, Identifiable, Hashable, Equatable {
+ let name: String
+ let url: String?
+ let online: Bool
+ let latency_ms: Double?
+ let detail: String?
+ var id: String { name }
+}
+
+struct ScenarioMeta: Decodable, Identifiable, Hashable {
+ let name: String
+ let path: String
+ let size: Int
+ let modified: Double
+ var id: String { name }
+}
+
+struct ScenarioDetail: Decodable {
+ let name: String
+ let yaml: String
+ let modified: Double?
+}
+
+struct ValidationResult: Decodable {
+ let ok: Bool
+ let errors: [String]
+ let warnings: [String]
+ let top_level_keys: [String]
+}
+
+struct CompileResult: Decodable {
+ let ok: Bool
+ let steps_count: Int
+ let entry_step_id: String?
+ let errors: [String]
+ let warnings: [String]
+}
+
+struct BoardInfo: Decodable, Identifiable, Hashable {
+ let name: String
+ let label: String
+ let type: String
+ let ip: String?
+ let mdns: String?
+ let hot_endpoint: String?
+ let cold_data_dir: String?
+ let espnow_relay_peers: [String]
+ var id: String { name }
+}
+
+struct FlashStep: Decodable, Hashable {
+ let label: String
+ let status: String
+ let detail: String
+}
+
+struct FlashResult: Decodable {
+ let board: String
+ let strategy_used: String
+ let ok: Bool
+ let steps: [FlashStep]
+ let ir_path: String?
+ let cold_command: String?
+ let relayed_to: [String]
+}
+
+struct HintResponse: Decodable {
+ let hint: String?
+ let level: Int?
+}
+
+struct TranscriptionResponse: Decodable {
+ let text: String?
+ let language: String?
+ let raw: String?
+}
+
+private extension Data {
+ mutating func appendString(_ s: String) { append(Data(s.utf8)) }
+}
diff --git a/apps/zacus-hub/Sources/Shared/HubSession.swift b/apps/zacus-hub/Sources/Shared/HubSession.swift
new file mode 100644
index 0000000..b8ba6ed
--- /dev/null
+++ b/apps/zacus-hub/Sources/Shared/HubSession.swift
@@ -0,0 +1,129 @@
+import Foundation
+import SwiftUI
+
+@MainActor
+final class HubSession: ObservableObject {
+ @Published var config: HubConfig
+ @Published var authStatus: AuthStatus = .unknown
+ @Published var lastError: String?
+ @Published var state: GameState = GameState()
+ @Published var streamActive: Bool = false
+
+ let api: HubAPI
+ private var streamTask: Task?
+
+ init() {
+ let cfg = HubConfig.load()
+ self.config = cfg
+ self.api = HubAPI(config: cfg)
+ }
+
+ func bootstrap() async {
+ await refreshAuth()
+ startStream()
+ }
+
+ func updateConfig(_ new: HubConfig) {
+ config = new
+ new.save()
+ Task {
+ await api.update(config: new)
+ await refreshAuth()
+ restartStream()
+ }
+ }
+
+ func refreshAuth() async {
+ do {
+ try await api.ping()
+ authStatus = .ok
+ lastError = nil
+ } catch {
+ authStatus = .failed
+ lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ }
+ }
+
+ func restartStream() {
+ streamTask?.cancel()
+ streamActive = false
+ startStream()
+ }
+
+ private func startStream() {
+ guard !config.token.isEmpty else { return }
+ streamTask = Task { [weak self] in
+ guard let self else { return }
+ while !Task.isCancelled {
+ do {
+ let socket = try await self.api.openStateSocket()
+ await MainActor.run { self.streamActive = true }
+ try await self.consume(socket: socket)
+ } catch {
+ await MainActor.run {
+ self.streamActive = false
+ self.lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ }
+ }
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ }
+ await MainActor.run { self.streamActive = false }
+ }
+ }
+
+ private func consume(socket: URLSessionWebSocketTask) async throws {
+ while !Task.isCancelled {
+ let message = try await socket.receive()
+ switch message {
+ case .string(let s):
+ if let data = s.data(using: .utf8), let decoded = try? JSONDecoder().decode(GameState.self, from: data) {
+ await MainActor.run { self.state = decoded }
+ }
+ case .data(let d):
+ if let decoded = try? JSONDecoder().decode(GameState.self, from: d) {
+ await MainActor.run { self.state = decoded }
+ }
+ @unknown default:
+ continue
+ }
+ }
+ }
+}
+
+enum AuthStatus { case unknown, ok, failed }
+
+struct HubConfig: Codable, Equatable {
+ var baseURL: String
+ var token: String
+
+ // Defaults are deliberately credential-free — never commit a token here.
+ // Users paste their gateway token via Settings on first launch; it lands
+ // in the system Keychain via `KeychainStore.setToken`.
+ static let defaults = HubConfig(
+ baseURL: "http://electron-server:8400",
+ token: ""
+ )
+
+ static func load() -> HubConfig {
+ let defs = UserDefaults.standard
+ let storedToken = KeychainStore.token() ?? ""
+ return HubConfig(
+ baseURL: defs.string(forKey: "hub.baseURL") ?? defaults.baseURL,
+ token: storedToken
+ )
+ }
+
+ func save() {
+ UserDefaults.standard.set(baseURL, forKey: "hub.baseURL")
+ KeychainStore.setToken(token)
+ }
+}
+
+struct GameState: Codable, Equatable {
+ var scene_id: String?
+ var scene_index: Int = 0
+ var last_hint: String?
+ var voice_session: String?
+ var backends: [BackendHealth] = []
+}
+
diff --git a/apps/zacus-hub/Sources/Shared/KeychainStore.swift b/apps/zacus-hub/Sources/Shared/KeychainStore.swift
new file mode 100644
index 0000000..59eb08d
--- /dev/null
+++ b/apps/zacus-hub/Sources/Shared/KeychainStore.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Security
+
+enum KeychainStore {
+ private static let service = "cc.saillant.zacus.hub"
+ private static let account = "gateway-token"
+
+ static func token() -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data,
+ let value = String(data: data, encoding: .utf8) else { return nil }
+ return value
+ }
+
+ @discardableResult
+ static func setToken(_ value: String) -> Bool {
+ let data = Data(value.utf8)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ SecItemDelete(query as CFDictionary)
+ guard !value.isEmpty else { return true }
+ var attrs = query
+ attrs[kSecValueData as String] = data
+ return SecItemAdd(attrs as CFDictionary, nil) == errSecSuccess
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlockCanvasView.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlockCanvasView.swift
new file mode 100644
index 0000000..5e379db
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlockCanvasView.swift
@@ -0,0 +1,294 @@
+import SwiftUI
+
+#if canImport(UniformTypeIdentifiers)
+import UniformTypeIdentifiers
+#endif
+
+struct BlockCanvasView: View {
+ @Binding var document: BlocksDocument
+ @Binding var selection: Set
+ @Binding var zoom: CGFloat
+ var onMutate: (BlocksDocument) -> Void = { _ in }
+
+ @State private var dragOffsets: [UUID: CGSize] = [:]
+ @State private var hoveredSlot: SlotTarget?
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .topLeading) {
+ Color.gray.opacity(0.06)
+ .onTapGesture { selection.removeAll() }
+ gridBackground(size: geo.size)
+ ForEach(document.nodes) { node in
+ blockBinding(node).map { binding in
+ BlockView(
+ node: binding,
+ isSelected: selection.contains(node.id),
+ onSelect: { toggleSelect(node.id) },
+ onDelete: { snapshot(); document.remove(node.id); selection.remove(node.id); onMutate(document) }
+ )
+ .position(positionOf(node))
+ .gesture(dragGesture(for: node))
+ }
+ }
+ connectorLayer
+ if let hoveredSlot { slotHighlight(hoveredSlot) }
+ }
+ .frame(minWidth: max(geo.size.width, contentExtent.width) * zoom,
+ minHeight: max(geo.size.height, contentExtent.height) * zoom,
+ alignment: .topLeading)
+ .scaleEffect(zoom, anchor: .topLeading)
+ .animation(.easeOut(duration: 0.12), value: zoom)
+ #if os(macOS)
+ .dropDestination(for: PaletteDrop.self) { drops, location in
+ guard let drop = drops.first else { return false }
+ let pos = CGPoint(x: location.x / zoom - blockWidth/2, y: location.y / zoom - 20)
+ snapshot()
+ let spec = BlockCatalog.spec(drop.kind)
+ let node = BlockNode(kind: drop.kind, position: pos, params: spec.defaultParams())
+ document.append(node)
+ onMutate(document)
+ selection = [node.id]
+ return true
+ } isTargeted: { _ in }
+ #endif
+ }
+ }
+
+ // MARK: helpers
+
+ private func toggleSelect(_ id: UUID) {
+ #if os(macOS)
+ let shift = NSEvent.modifierFlags.contains(.shift)
+ #else
+ let shift = false
+ #endif
+ if shift {
+ if selection.contains(id) { selection.remove(id) } else { selection.insert(id) }
+ } else {
+ selection = [id]
+ }
+ }
+
+ private func snapshot() {
+ UndoBroker.shared.push(document)
+ }
+
+ private func positionOf(_ node: BlockNode) -> CGPoint {
+ let offset = dragOffsets[node.id] ?? .zero
+ let h = estimatedHeight(of: node)
+ return CGPoint(x: node.position.x + blockWidth/2 + offset.width,
+ y: node.position.y + h/2 + offset.height)
+ }
+
+ private func blockBinding(_ node: BlockNode) -> Binding? {
+ guard let idx = document.nodes.firstIndex(where: { $0.id == node.id }) else { return nil }
+ return $document.nodes[idx]
+ }
+
+ private var contentExtent: CGSize {
+ let xs = document.nodes.map { $0.position.x + blockWidth + 80 }
+ let ys = document.nodes.map { $0.position.y + estimatedHeight(of: $0) + 80 }
+ return CGSize(width: xs.max() ?? 600, height: ys.max() ?? 400)
+ }
+
+ // MARK: drag + snap
+
+ private func dragGesture(for node: BlockNode) -> some Gesture {
+ DragGesture(minimumDistance: 1, coordinateSpace: .local)
+ .onChanged { value in
+ dragOffsets[node.id] = value.translation
+ if !selection.contains(node.id) { selection = [node.id] }
+ // live preview snap target
+ let liveTop = CGPoint(x: node.position.x + value.translation.width,
+ y: node.position.y + value.translation.height)
+ hoveredSlot = findSlotTarget(droppingTop: liveTop, dragged: node.id)
+ }
+ .onEnded { value in
+ dragOffsets[node.id] = nil
+ hoveredSlot = nil
+ guard let idx = document.nodes.firstIndex(where: { $0.id == node.id }) else { return }
+ snapshot()
+ // detach this block from any parent + slot
+ detach(node.id)
+ var moved = document.nodes[idx]
+ moved.position.x += value.translation.width
+ moved.position.y += value.translation.height
+ document.nodes[idx] = moved
+ snap(node: moved)
+ onMutate(document)
+ }
+ }
+
+ private func detach(_ id: UUID) {
+ for i in document.nodes.indices where document.nodes[i].nextID == id {
+ document.nodes[i].nextID = nil
+ }
+ for i in document.nodes.indices {
+ document.nodes[i].slots = document.nodes[i].slots.filter { $0.value != id }
+ }
+ }
+
+ private struct SlotTarget {
+ let parentID: UUID
+ let slotName: String? // nil = bottom (regular next)
+ let anchor: CGPoint // top-left where moved block should land
+ }
+
+ private func findSlotTarget(droppingTop top: CGPoint, dragged: UUID) -> SlotTarget? {
+ let chainIDs = Set(document.chain(from: dragged).map(\.id))
+ var best: (SlotTarget, CGFloat)?
+ for other in document.nodes where other.id != dragged && !chainIDs.contains(other.id) {
+ let spec = BlockCatalog.spec(other.kind)
+ // (a) regular bottom snap
+ let bottom = CGPoint(x: other.position.x, y: other.position.y + estimatedHeight(of: other))
+ let dx = abs(bottom.x - top.x), dy = abs(bottom.y - top.y)
+ if dx < snapDistance && dy < snapDistance {
+ let cand = SlotTarget(parentID: other.id, slotName: nil, anchor: bottom)
+ if best == nil || (dx+dy) < best!.1 { best = (cand, dx+dy) }
+ }
+ // (b) slot mouths — approximate vertical placement after params
+ var slotY = other.position.y + 38
+ for p in spec.params { slotY += (p.kind == .multiline ? 78 : 38) }
+ for slot in spec.slots {
+ let mouth = CGPoint(x: other.position.x + 22, y: slotY + 34)
+ let sdx = abs(mouth.x - top.x), sdy = abs(mouth.y - top.y)
+ if sdx < snapDistance*1.5 && sdy < snapDistance*1.5 {
+ let cand = SlotTarget(parentID: other.id, slotName: slot.name, anchor: mouth)
+ if best == nil || (sdx+sdy) < best!.1 { best = (cand, sdx+sdy) }
+ }
+ slotY += 34
+ }
+ }
+ return best?.0
+ }
+
+ private func snap(node moved: BlockNode) {
+ guard let target = findSlotTarget(droppingTop: moved.position, dragged: moved.id) else { return }
+ guard let mIdx = document.nodes.firstIndex(where: { $0.id == moved.id }),
+ let pIdx = document.nodes.firstIndex(where: { $0.id == target.parentID }) else { return }
+ if let slotName = target.slotName {
+ // slot snap: place at the head of the slot chain, push prior head after our tail
+ let prior = document.nodes[pIdx].slots[slotName]
+ document.nodes[mIdx].position = target.anchor
+ document.nodes[pIdx].slots[slotName] = moved.id
+ if let prior, prior != moved.id {
+ let tail = document.chain(from: moved.id).last ?? moved
+ if let tIdx = document.nodes.firstIndex(where: { $0.id == tail.id }) {
+ document.nodes[tIdx].nextID = prior
+ }
+ }
+ } else {
+ // regular bottom chain
+ document.nodes[mIdx].position = target.anchor
+ let prior = document.nodes[pIdx].nextID
+ document.nodes[pIdx].nextID = moved.id
+ if let prior, prior != moved.id {
+ let tail = document.chain(from: moved.id).last ?? moved
+ if let tIdx = document.nodes.firstIndex(where: { $0.id == tail.id }) {
+ document.nodes[tIdx].nextID = prior
+ }
+ }
+ }
+ cascadePositions(from: target.parentID)
+ }
+
+ private func cascadePositions(from rootID: UUID) {
+ guard let root = document.node(rootID) else { return }
+ // Cascade vertical chain
+ var cur: UUID? = root.nextID
+ var y: CGFloat = root.position.y + estimatedHeight(of: root)
+ let x: CGFloat = root.position.x
+ var seen = Set()
+ while let id = cur, !seen.contains(id), let idx = document.nodes.firstIndex(where: { $0.id == id }) {
+ seen.insert(id)
+ document.nodes[idx].position = CGPoint(x: x, y: y)
+ y += estimatedHeight(of: document.nodes[idx])
+ cur = document.nodes[idx].nextID
+ }
+ // Cascade slot contents
+ let spec = BlockCatalog.spec(root.kind)
+ var slotY = root.position.y + 38
+ for p in spec.params { slotY += (p.kind == .multiline ? 78 : 38) }
+ for slot in spec.slots {
+ if let head = root.slots[slot.name] {
+ cascadePositions(from: head)
+ if let hIdx = document.nodes.firstIndex(where: { $0.id == head }) {
+ document.nodes[hIdx].position = CGPoint(x: root.position.x + 20, y: slotY + 36)
+ cascadePositions(from: head) // re-flow with corrected head
+ }
+ }
+ slotY += 34
+ }
+ }
+
+ // MARK: visuals
+
+ @ViewBuilder private var connectorLayer: some View {
+ Canvas { context, _ in
+ for node in document.nodes {
+ if let nextID = node.nextID, let next = document.node(nextID) {
+ drawWire(context: context, from: node, to: next, color: .white.opacity(0.7))
+ }
+ for (_, head) in node.slots {
+ if let h = document.node(head) {
+ drawWire(context: context, from: node, to: h, color: .yellow.opacity(0.8))
+ }
+ }
+ }
+ }
+ .allowsHitTesting(false)
+ }
+
+ private func drawWire(context: GraphicsContext, from: BlockNode, to: BlockNode, color: Color) {
+ let a = CGPoint(x: from.position.x + 16, y: from.position.y + estimatedHeight(of: from))
+ let b = CGPoint(x: to.position.x + 16, y: to.position.y)
+ var path = Path()
+ path.move(to: a); path.addLine(to: b)
+ context.stroke(path, with: .color(color), lineWidth: 3)
+ }
+
+ @ViewBuilder private func slotHighlight(_ t: SlotTarget) -> some View {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(.green, lineWidth: 3)
+ .frame(width: blockWidth, height: 26)
+ .position(x: t.anchor.x + blockWidth/2, y: t.anchor.y + 13)
+ .allowsHitTesting(false)
+ }
+
+ private func gridBackground(size: CGSize) -> some View {
+ Canvas { context, canvasSize in
+ let step: CGFloat = 24
+ var path = Path()
+ var x: CGFloat = 0
+ while x < canvasSize.width { path.move(to: CGPoint(x: x, y: 0)); path.addLine(to: CGPoint(x: x, y: canvasSize.height)); x += step }
+ var y: CGFloat = 0
+ while y < canvasSize.height { path.move(to: CGPoint(x: 0, y: y)); path.addLine(to: CGPoint(x: canvasSize.width, y: y)); y += step }
+ context.stroke(path, with: .color(.gray.opacity(0.08)), lineWidth: 0.5)
+ }
+ .frame(width: max(size.width, contentExtent.width), height: max(size.height, contentExtent.height))
+ .allowsHitTesting(false)
+ }
+}
+
+// MARK: - Palette drop transfer
+
+struct PaletteDrop: Codable, Transferable {
+ let kind: BlockKind
+
+ static var transferRepresentation: some TransferRepresentation {
+ CodableRepresentation(contentType: .paletteDrop)
+ }
+}
+
+extension UTType {
+ static let paletteDrop = UTType(exportedAs: "cc.saillant.zacus.hub.palette-drop")
+}
+
+// MARK: - Tiny undo broker shared with editor view
+
+final class UndoBroker {
+ static let shared = UndoBroker()
+ var onSnapshot: ((BlocksDocument) -> Void)?
+ func push(_ doc: BlocksDocument) { onSnapshot?(doc) }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlockCatalog.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlockCatalog.swift
new file mode 100644
index 0000000..dc935c8
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlockCatalog.swift
@@ -0,0 +1,219 @@
+import Foundation
+import SwiftUI
+
+/// Static metadata for each BlockKind: label, parameter schema, default values.
+struct BlockSpec {
+ let kind: BlockKind
+ let title: String
+ let summary: String
+ let params: [ParamSpec]
+ /// Named child slots (Scratch C-shape). Empty for simple blocks.
+ let slots: [SlotSpec]
+
+ struct SlotSpec { let name: String; let label: String }
+
+ init(kind: BlockKind, title: String, summary: String, params: [ParamSpec], slots: [SlotSpec] = []) {
+ self.kind = kind
+ self.title = title
+ self.summary = summary
+ self.params = params
+ self.slots = slots
+ }
+
+ struct ParamSpec {
+ let name: String
+ let label: String
+ let kind: ParamKind
+ let placeholder: String
+ let defaultValue: String
+ }
+
+ enum ParamKind: Equatable { case text, multiline, number, identifier, choice([String]) }
+
+ func defaultParams() -> [String: String] {
+ Dictionary(uniqueKeysWithValues: params.map { ($0.name, $0.defaultValue) })
+ }
+}
+
+enum BlockCatalog {
+
+ static let all: [BlockSpec] = [
+ // Scene
+ BlockSpec(kind: .sceneStart, title: "Début de scène", summary: "Marque le début d'une scène nommée.",
+ params: [.init(name: "id", label: "Identifiant", kind: .identifier, placeholder: "intro", defaultValue: "scene_id")]),
+ BlockSpec(kind: .sceneEnd, title: "Fin de scène", summary: "Termine la scène courante.", params: []),
+ BlockSpec(kind: .sceneGoto, title: "Aller à la scène", summary: "Saut inconditionnel.",
+ params: [.init(name: "target", label: "Cible", kind: .identifier, placeholder: "next_scene", defaultValue: "")]),
+ BlockSpec(kind: .sceneBranch, title: "Branche conditionnelle", summary: "Si la condition est vraie, aller à A sinon B.",
+ params: [
+ .init(name: "condition", label: "Condition", kind: .text, placeholder: "score > 5", defaultValue: ""),
+ .init(name: "ifTrue", label: "Si vrai", kind: .identifier, placeholder: "scene_a", defaultValue: ""),
+ .init(name: "ifFalse", label: "Sinon", kind: .identifier, placeholder: "scene_b", defaultValue: "")
+ ]),
+
+ // NPC
+ BlockSpec(kind: .npcSay, title: "Zacus dit", summary: "Diffuse une réplique via TTS.",
+ params: [.init(name: "text", label: "Texte", kind: .multiline, placeholder: "Bonjour, je suis le professeur Zacus.", defaultValue: "")]),
+ BlockSpec(kind: .npcWaitResponse, title: "Attendre une réponse", summary: "Bloque jusqu'à fin d'utterance.",
+ params: [.init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "10", defaultValue: "10")]),
+ BlockSpec(kind: .npcIntentMatch, title: "Si l'intent =", summary: "Match l'intent renvoyé par le NPC.",
+ params: [
+ .init(name: "intent", label: "Intent", kind: .identifier, placeholder: "yes", defaultValue: ""),
+ .init(name: "then", label: "Alors aller à", kind: .identifier, placeholder: "scene_x", defaultValue: "")
+ ]),
+
+ // Hardware
+ BlockSpec(kind: .hwServo, title: "Servo", summary: "Pose un angle sur un servo (MCPWM).",
+ params: [
+ .init(name: "channel", label: "Canal", kind: .number, placeholder: "0", defaultValue: "0"),
+ .init(name: "angle", label: "Angle (°)", kind: .number, placeholder: "90", defaultValue: "90")
+ ]),
+ BlockSpec(kind: .hwReadQR, title: "Lire un QR", summary: "Attend un scan QR de la caméra.",
+ params: [.init(name: "expected", label: "Attendu", kind: .text, placeholder: "ZAC-A1", defaultValue: "")]),
+ BlockSpec(kind: .hwLEDPattern, title: "LED — motif", summary: "Joue un motif LED nommé.",
+ params: [.init(name: "pattern", label: "Motif", kind: .choice(["rainbow","blink","fade","off"]), placeholder: "rainbow", defaultValue: "rainbow")]),
+ BlockSpec(kind: .hwSoundPlay, title: "Jouer un son", summary: "Joue un asset audio du media manager.",
+ params: [.init(name: "asset", label: "Asset", kind: .identifier, placeholder: "sting_win", defaultValue: "")]),
+ BlockSpec(kind: .hwAudioStop, title: "Stopper l'audio", summary: "Coupe le canal audio en cours.", params: []),
+ BlockSpec(kind: .hwAudioVolume, title: "Volume audio", summary: "Règle le volume (0-100).",
+ params: [.init(name: "level", label: "Niveau", kind: .number, placeholder: "70", defaultValue: "70")]),
+
+ BlockSpec(kind: .hwLCDText, title: "LCD — texte",
+ summary: "Affiche du texte (compatible LCD générique, BOX-3, M5Stack).",
+ params: [
+ .init(name: "line", label: "Ligne", kind: .number, placeholder: "0", defaultValue: "0"),
+ .init(name: "text", label: "Texte", kind: .text, placeholder: "Bienvenue", defaultValue: "")
+ ]),
+ BlockSpec(kind: .hwLCDClear, title: "LCD — effacer", summary: "Vide l'écran.", params: []),
+ BlockSpec(kind: .hwLCDImage, title: "LCD — image", summary: "Affiche un asset image plein écran.",
+ params: [.init(name: "asset", label: "Asset image", kind: .identifier, placeholder: "splash", defaultValue: "")]),
+ BlockSpec(kind: .hwLCDTouchWait, title: "LCD — attendre tap",
+ summary: "Bloque jusqu'à tap sur une zone touch ou timeout (BOX-3, M5Core2).",
+ params: [
+ .init(name: "zone", label: "Zone", kind: .identifier, placeholder: "btn_yes", defaultValue: ""),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "30", defaultValue: "30")
+ ]),
+
+ BlockSpec(kind: .hwBuzzerTone, title: "Buzzer — tone",
+ summary: "Émet un bip.",
+ params: [
+ .init(name: "freq", label: "Fréquence (Hz)", kind: .number, placeholder: "2000", defaultValue: "2000"),
+ .init(name: "ms", label: "Durée (ms)", kind: .number, placeholder: "120", defaultValue: "120")
+ ]),
+ BlockSpec(kind: .hwRelay, title: "Relais",
+ summary: "Bascule un relais (ouvre une porte, etc.).",
+ params: [
+ .init(name: "channel", label: "Canal", kind: .number, placeholder: "0", defaultValue: "0"),
+ .init(name: "state", label: "État", kind: .choice(["on","off","pulse"]), placeholder: "pulse", defaultValue: "pulse")
+ ]),
+ BlockSpec(kind: .hwSensorRead, title: "Lire capteur",
+ summary: "Lit un capteur analogique et stocke dans une variable.",
+ params: [
+ .init(name: "pin", label: "Pin / canal", kind: .identifier, placeholder: "A0", defaultValue: "A0"),
+ .init(name: "var", label: "Variable", kind: .identifier, placeholder: "lecture", defaultValue: "lecture")
+ ]),
+ BlockSpec(kind: .hwButtonWait, title: "Attendre bouton",
+ summary: "Bloque jusqu'à appui sur un bouton GPIO.",
+ params: [
+ .init(name: "button", label: "Bouton", kind: .identifier, placeholder: "btn_main", defaultValue: "btn_main"),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "0", defaultValue: "0")
+ ]),
+
+ // ESP-NOW
+ BlockSpec(kind: .espnowRegisterPeer, title: "ESP-NOW — déclarer peer",
+ summary: "Enregistre un peer par MAC.",
+ params: [
+ .init(name: "alias", label: "Alias", kind: .identifier, placeholder: "annexe1", defaultValue: ""),
+ .init(name: "mac", label: "MAC", kind: .text, placeholder: "AA:BB:CC:DD:EE:FF", defaultValue: "")
+ ]),
+ BlockSpec(kind: .espnowSend, title: "ESP-NOW — envoyer",
+ summary: "Envoie une commande à un peer.",
+ params: [
+ .init(name: "peer", label: "Peer (alias)", kind: .identifier, placeholder: "annexe1", defaultValue: ""),
+ .init(name: "command", label: "Commande", kind: .text, placeholder: "open_door", defaultValue: "")
+ ]),
+ BlockSpec(kind: .espnowBroadcast, title: "ESP-NOW — broadcast",
+ summary: "Envoie une commande à tous les peers.",
+ params: [.init(name: "command", label: "Commande", kind: .text, placeholder: "reset", defaultValue: "")]),
+ BlockSpec(kind: .espnowWait, title: "ESP-NOW — attendre",
+ summary: "Bloque jusqu'à réception d'une commande.",
+ params: [
+ .init(name: "command", label: "Commande attendue", kind: .text, placeholder: "ready", defaultValue: ""),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "10", defaultValue: "10")
+ ]),
+
+ // BOX-3
+ BlockSpec(kind: .boxIMUShake, title: "BOX-3 — secouer",
+ summary: "Attend une secousse détectée par l'IMU.",
+ params: [
+ .init(name: "threshold", label: "Seuil (g)", kind: .number, placeholder: "1.5", defaultValue: "1.5"),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "10", defaultValue: "10")
+ ]),
+ BlockSpec(kind: .boxIRSend, title: "BOX-3 — IR send",
+ summary: "Envoie un code IR (NEC, etc.).",
+ params: [
+ .init(name: "protocol", label: "Protocole", kind: .choice(["NEC","RC5","SONY","RAW"]), placeholder: "NEC", defaultValue: "NEC"),
+ .init(name: "code", label: "Code (hex)", kind: .identifier, placeholder: "0x20DF10EF", defaultValue: "")
+ ]),
+
+ // M5
+ BlockSpec(kind: .m5Beep, title: "M5 — bip",
+ summary: "Bip via le buzzer M5 (Core2/StickC).",
+ params: [
+ .init(name: "freq", label: "Fréquence (Hz)", kind: .number, placeholder: "4000", defaultValue: "4000"),
+ .init(name: "ms", label: "Durée (ms)", kind: .number, placeholder: "200", defaultValue: "200")
+ ]),
+ BlockSpec(kind: .m5LCDText, title: "M5 — texte LCD",
+ summary: "Affiche du texte sur l'écran M5 avec couleur.",
+ params: [
+ .init(name: "text", label: "Texte", kind: .text, placeholder: "Hello", defaultValue: ""),
+ .init(name: "color", label: "Couleur", kind: .choice(["white","yellow","red","green","blue","cyan","magenta"]), placeholder: "white", defaultValue: "white"),
+ .init(name: "size", label: "Taille", kind: .number, placeholder: "2", defaultValue: "2")
+ ]),
+ BlockSpec(kind: .m5ButtonAB, title: "M5 — attendre bouton",
+ summary: "Attend l'appui sur A, B (ou C sur Core2).",
+ params: [
+ .init(name: "button", label: "Bouton", kind: .choice(["A","B","C","any"]), placeholder: "A", defaultValue: "A"),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "0", defaultValue: "0")
+ ]),
+ BlockSpec(kind: .m5RGBLed, title: "M5 — LED RGB",
+ summary: "Allume la LED RGB (StickC) en couleur hex.",
+ params: [.init(name: "color", label: "Couleur (hex)", kind: .identifier, placeholder: "#FF8800", defaultValue: "#FF8800")]),
+ BlockSpec(kind: .m5IMUShake, title: "M5 — secouer",
+ summary: "Attend une secousse (MPU6886).",
+ params: [
+ .init(name: "threshold", label: "Seuil (g)", kind: .number, placeholder: "1.5", defaultValue: "1.5"),
+ .init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "10", defaultValue: "10")
+ ]),
+
+ // PLIP
+ BlockSpec(kind: .plipRing, title: "PLIP — sonner",
+ summary: "Déclenche la sonnerie du téléphone rétro.",
+ params: [.init(name: "duration_s", label: "Durée (s)", kind: .number, placeholder: "3", defaultValue: "3")]),
+ BlockSpec(kind: .plipPickupWait, title: "PLIP — attendre décroché",
+ summary: "Bloque jusqu'à décroché du combiné.",
+ params: [.init(name: "timeout_s", label: "Timeout (s)", kind: .number, placeholder: "30", defaultValue: "30")]),
+
+ // Logic
+ BlockSpec(kind: .logicIf, title: "Si …", summary: "Exécute la chaîne attachée seulement si vrai.",
+ params: [.init(name: "condition", label: "Condition", kind: .text, placeholder: "score > 0", defaultValue: "")],
+ slots: [.init(name: "body", label: "Alors"), .init(name: "else", label: "Sinon")]),
+ BlockSpec(kind: .logicTimer, title: "Timer", summary: "Attend N secondes.",
+ params: [.init(name: "seconds", label: "Secondes", kind: .number, placeholder: "5", defaultValue: "5")]),
+ BlockSpec(kind: .logicScore, title: "Score", summary: "Ajoute N points (négatif pour retirer).",
+ params: [.init(name: "delta", label: "Delta", kind: .number, placeholder: "1", defaultValue: "1")]),
+ BlockSpec(kind: .logicSetVar, title: "Variable :=", summary: "Définit une variable de scénario.",
+ params: [
+ .init(name: "name", label: "Nom", kind: .identifier, placeholder: "key", defaultValue: ""),
+ .init(name: "value", label: "Valeur", kind: .text, placeholder: "42", defaultValue: "")
+ ]),
+ ]
+
+ static let byKind: [BlockKind: BlockSpec] = Dictionary(uniqueKeysWithValues: all.map { ($0.kind, $0) })
+
+ static func spec(_ kind: BlockKind) -> BlockSpec { byKind[kind]! }
+
+ static func byCategory() -> [(BlockCategory, [BlockSpec])] {
+ BlockCategory.allCases.map { cat in (cat, all.filter { $0.kind.category == cat }) }
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlockModel.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlockModel.swift
new file mode 100644
index 0000000..b66f377
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlockModel.swift
@@ -0,0 +1,144 @@
+import Foundation
+import SwiftUI
+
+// MARK: - Categories
+
+enum BlockCategory: String, CaseIterable, Identifiable, Codable {
+ case scene, npc, audio, lcd, hardware, espnow, box3, m5, plip, logic
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .scene: return "Scènes"
+ case .npc: return "NPC (Zacus)"
+ case .audio: return "Audio"
+ case .lcd: return "Affichage LCD"
+ case .hardware: return "Hardware ESP32"
+ case .espnow: return "ESP-NOW"
+ case .box3: return "ESP32-S3-BOX-3"
+ case .m5: return "M5Stack / M5StickC"
+ case .plip: return "PLIP (téléphone)"
+ case .logic: return "Logique"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .scene: return Color(red: 0.32, green: 0.55, blue: 0.95)
+ case .npc: return Color(red: 0.85, green: 0.36, blue: 0.45)
+ case .audio: return Color(red: 0.65, green: 0.45, blue: 0.85)
+ case .lcd: return Color(red: 0.20, green: 0.55, blue: 0.75)
+ case .hardware: return Color(red: 0.18, green: 0.68, blue: 0.55)
+ case .espnow: return Color(red: 0.95, green: 0.50, blue: 0.20)
+ case .box3: return Color(red: 0.55, green: 0.35, blue: 0.20)
+ case .m5: return Color(red: 0.30, green: 0.45, blue: 0.65)
+ case .plip: return Color(red: 0.78, green: 0.30, blue: 0.55)
+ case .logic: return Color(red: 0.95, green: 0.65, blue: 0.20)
+ }
+ }
+}
+
+// MARK: - Kinds (15 prioritized)
+
+enum BlockKind: String, CaseIterable, Codable {
+ // Scene
+ case sceneStart, sceneEnd, sceneGoto, sceneBranch
+ // NPC
+ case npcSay, npcWaitResponse, npcIntentMatch
+ // Audio
+ case hwSoundPlay, hwAudioStop, hwAudioVolume
+ // LCD
+ case hwLCDText, hwLCDClear, hwLCDImage, hwLCDTouchWait
+ // Hardware
+ case hwServo, hwReadQR, hwLEDPattern, hwBuzzerTone, hwRelay, hwSensorRead, hwButtonWait
+ // ESP-NOW
+ case espnowRegisterPeer, espnowSend, espnowBroadcast, espnowWait
+ // ESP32-S3-BOX-3
+ case boxIMUShake, boxIRSend
+ // M5Stack / M5StickC
+ case m5Beep, m5LCDText, m5ButtonAB, m5RGBLed, m5IMUShake
+ // PLIP
+ case plipRing, plipPickupWait
+ // Logic
+ case logicIf, logicTimer, logicScore, logicSetVar
+
+ var category: BlockCategory {
+ switch self {
+ case .sceneStart, .sceneEnd, .sceneGoto, .sceneBranch: return .scene
+ case .npcSay, .npcWaitResponse, .npcIntentMatch: return .npc
+ case .hwSoundPlay, .hwAudioStop, .hwAudioVolume: return .audio
+ case .hwLCDText, .hwLCDClear, .hwLCDImage, .hwLCDTouchWait: return .lcd
+ case .hwServo, .hwReadQR, .hwLEDPattern, .hwBuzzerTone, .hwRelay, .hwSensorRead, .hwButtonWait: return .hardware
+ case .espnowRegisterPeer, .espnowSend, .espnowBroadcast, .espnowWait: return .espnow
+ case .boxIMUShake, .boxIRSend: return .box3
+ case .m5Beep, .m5LCDText, .m5ButtonAB, .m5RGBLed, .m5IMUShake: return .m5
+ case .plipRing, .plipPickupWait: return .plip
+ case .logicIf, .logicTimer, .logicScore, .logicSetVar: return .logic
+ }
+ }
+}
+
+// MARK: - Block instance
+
+struct BlockNode: Identifiable, Codable, Equatable {
+ let id: UUID
+ var kind: BlockKind
+ var position: CGPoint
+ var params: [String: String]
+ /// id of the block snapped under this one (Scratch-style vertical chain).
+ var nextID: UUID?
+ /// Child slot heads, keyed by slot name (e.g. "body", "else"). The slot's
+ /// chain is the linear list reachable via .nextID from this head.
+ var slots: [String: UUID]
+
+ init(id: UUID = UUID(), kind: BlockKind, position: CGPoint, params: [String: String] = [:], nextID: UUID? = nil, slots: [String: UUID] = [:]) {
+ self.id = id
+ self.kind = kind
+ self.position = position
+ self.params = params
+ self.nextID = nextID
+ self.slots = slots
+ }
+}
+
+// MARK: - Document
+
+struct BlocksDocument: Codable, Equatable {
+ var nodes: [BlockNode] = []
+ /// Roots (no parent pointing to them) ordered as they appear on canvas.
+ func roots() -> [BlockNode] {
+ var referenced = Set(nodes.compactMap(\.nextID))
+ for n in nodes { for s in n.slots.values { referenced.insert(s) } }
+ return nodes.filter { !referenced.contains($0.id) }
+ }
+ func node(_ id: UUID) -> BlockNode? { nodes.first(where: { $0.id == id }) }
+ mutating func update(_ node: BlockNode) {
+ if let idx = nodes.firstIndex(where: { $0.id == node.id }) {
+ nodes[idx] = node
+ }
+ }
+ mutating func append(_ node: BlockNode) { nodes.append(node) }
+ mutating func remove(_ id: UUID) {
+ // detach from any chain
+ for i in nodes.indices where nodes[i].nextID == id {
+ nodes[i].nextID = nil
+ }
+ // detach from any slot
+ for i in nodes.indices {
+ nodes[i].slots = nodes[i].slots.filter { $0.value != id }
+ }
+ nodes.removeAll { $0.id == id }
+ }
+ /// Walk a chain starting at `start`, returning ordered nodes.
+ func chain(from start: UUID) -> [BlockNode] {
+ var out: [BlockNode] = []
+ var cur: UUID? = start
+ var seen = Set()
+ while let id = cur, !seen.contains(id), let n = node(id) {
+ out.append(n)
+ seen.insert(id)
+ cur = n.nextID
+ }
+ return out
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlockPaletteView.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlockPaletteView.swift
new file mode 100644
index 0000000..a570dd7
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlockPaletteView.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+/// Sidebar listing block templates. Drag onto canvas, or click "+" for centre spawn.
+struct BlockPaletteView: View {
+ var onAdd: (BlockKind) -> Void
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(BlockCatalog.byCategory(), id: \.0) { category, specs in
+ section(category, specs: specs)
+ }
+ }
+ .padding(12)
+ }
+ .frame(width: 240)
+ .background(.thinMaterial)
+ }
+
+ private func section(_ cat: BlockCategory, specs: [BlockSpec]) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Circle().fill(cat.color).frame(width: 10, height: 10)
+ Text(cat.label).font(.subheadline.bold())
+ }
+ ForEach(specs, id: \.kind) { spec in
+ tile(spec, color: cat.color)
+ }
+ }
+ }
+
+ private func tile(_ spec: BlockSpec, color: Color) -> some View {
+ HStack {
+ Text(spec.title).font(.callout).foregroundStyle(.primary)
+ Spacer()
+ Button { onAdd(spec.kind) } label: {
+ Image(systemName: "plus.circle").foregroundStyle(color)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.vertical, 6).padding(.horizontal, 8)
+ .background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.12)))
+ .contentShape(Rectangle())
+ #if os(macOS)
+ .draggable(PaletteDrop(kind: spec.kind)) {
+ // drag preview
+ HStack(spacing: 6) {
+ Circle().fill(color).frame(width: 8, height: 8)
+ Text(spec.title).font(.caption.bold())
+ }
+ .padding(6)
+ .background(.thinMaterial)
+ .cornerRadius(6)
+ }
+ #endif
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlockView.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlockView.swift
new file mode 100644
index 0000000..f6d1cdd
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlockView.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+
+/// Visual representation of a block on the canvas.
+struct BlockView: View {
+ @Binding var node: BlockNode
+ var isSelected: Bool
+ var onSelect: () -> Void
+ var onDelete: () -> Void
+
+ private var spec: BlockSpec { BlockCatalog.spec(node.kind) }
+ private var color: Color { node.kind.category.color }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ header
+ ForEach(spec.params, id: \.name) { p in
+ paramField(p)
+ }
+ ForEach(spec.slots, id: \.name) { slot in
+ slotMouth(slot)
+ }
+ }
+ .padding(10)
+ .frame(width: blockWidth, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(color.opacity(0.92))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .stroke(isSelected ? Color.white : Color.black.opacity(0.18), lineWidth: isSelected ? 2.5 : 1)
+ )
+ .shadow(color: .black.opacity(isSelected ? 0.25 : 0.12), radius: isSelected ? 8 : 3, x: 0, y: 2)
+ .foregroundStyle(.white)
+ .contentShape(Rectangle())
+ .onTapGesture { onSelect() }
+ .contextMenu {
+ Button(role: .destructive) { onDelete() } label: { Label("Supprimer", systemImage: "trash") }
+ }
+ }
+
+ private var header: some View {
+ HStack(spacing: 6) {
+ Image(systemName: icon).font(.caption.bold())
+ Text(spec.title).font(.subheadline.bold()).lineLimit(1)
+ Spacer(minLength: 4)
+ }
+ }
+
+ private var icon: String {
+ switch node.kind.category {
+ case .scene: return "rectangle.stack"
+ case .npc: return "person.wave.2"
+ case .audio: return "speaker.wave.2"
+ case .lcd: return "display"
+ case .hardware: return "cpu"
+ case .espnow: return "wifi.router"
+ case .box3: return "shippingbox"
+ case .m5: return "rectangle.3.group"
+ case .plip: return "phone.fill"
+ case .logic: return "function"
+ }
+ }
+
+ @ViewBuilder private func slotMouth(_ slot: BlockSpec.SlotSpec) -> some View {
+ let filled = node.slots[slot.name] != nil
+ HStack {
+ Text(slot.label).font(.caption.bold()).foregroundStyle(.white.opacity(0.9))
+ Spacer()
+ Image(systemName: filled ? "chevron.down.circle.fill" : "chevron.down.circle.dotted")
+ .foregroundStyle(.white.opacity(filled ? 1 : 0.6))
+ }
+ .padding(.horizontal, 8).padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [4,3]))
+ .foregroundStyle(.white.opacity(filled ? 0.0 : 0.65))
+ )
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .fill(.black.opacity(0.18))
+ )
+ }
+
+ @ViewBuilder private func paramField(_ p: BlockSpec.ParamSpec) -> some View {
+ let binding = Binding(
+ get: { node.params[p.name] ?? "" },
+ set: { node.params[p.name] = $0 }
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ Text(p.label).font(.caption2).foregroundStyle(.white.opacity(0.85))
+ switch p.kind {
+ case .multiline:
+ TextEditor(text: binding)
+ .font(.caption.monospaced())
+ .frame(minHeight: 44, maxHeight: 70)
+ .scrollContentBackground(.hidden)
+ .background(Color.black.opacity(0.18))
+ .cornerRadius(4)
+ case .choice(let opts):
+ Picker(p.label, selection: binding) {
+ ForEach(opts, id: \.self) { Text($0).tag($0) }
+ }
+ .pickerStyle(.menu)
+ .labelsHidden()
+ .tint(.white)
+ default:
+ TextField(p.placeholder, text: binding)
+ .textFieldStyle(.plain)
+ .font(.caption.monospaced())
+ .padding(.horizontal, 6).padding(.vertical, 3)
+ .background(Color.black.opacity(0.18))
+ .cornerRadius(4)
+ }
+ }
+ }
+}
+
+let blockWidth: CGFloat = 240
+let blockMinHeight: CGFloat = 56
+let snapDistance: CGFloat = 28
+
+/// Approximate vertical height for layout/snap calc.
+func estimatedHeight(of node: BlockNode) -> CGFloat {
+ let spec = BlockCatalog.spec(node.kind)
+ var h: CGFloat = 38 // header + padding
+ for p in spec.params {
+ switch p.kind {
+ case .multiline: h += 78
+ default: h += 38
+ }
+ }
+ h += CGFloat(spec.slots.count) * 34
+ return max(blockMinHeight, h)
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlocksEditorView.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlocksEditorView.swift
new file mode 100644
index 0000000..94dcbe0
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlocksEditorView.swift
@@ -0,0 +1,216 @@
+import SwiftUI
+
+struct BlocksEditorView: View {
+ @EnvironmentObject var session: HubSession
+ let scenarioName: String
+
+ @State private var document = BlocksDocument()
+ @State private var selection: Set = []
+ @State private var dirty = false
+ @State private var saving = false
+ @State private var compiling = false
+ @State private var message: String?
+ @State private var error: String?
+ @State private var zoom: CGFloat = 1.0
+ @State private var liveZoom: CGFloat = 1.0
+ @State private var undoStack: [BlocksDocument] = []
+ @State private var redoStack: [BlocksDocument] = []
+
+ var body: some View {
+ VStack(spacing: 0) {
+ toolbar
+ Divider()
+ HStack(spacing: 0) {
+ BlockPaletteView(onAdd: addBlock)
+ Divider()
+ ZStack {
+ ScrollView([.horizontal, .vertical]) {
+ BlockCanvasView(
+ document: $document,
+ selection: $selection,
+ zoom: $zoom,
+ onMutate: { _ in dirty = true }
+ )
+ .frame(minWidth: 1600, minHeight: 1100)
+ }
+ .background(Color.gray.opacity(0.04))
+ if document.nodes.isEmpty {
+ ContentUnavailableView("Toile vide",
+ systemImage: "rectangle.dashed",
+ description: Text("Glisse un bloc depuis la palette à gauche, ou clique le + pour l'ajouter."))
+ }
+ }
+ #if os(macOS)
+ .gesture(
+ MagnificationGesture()
+ .onChanged { value in
+ liveZoom = max(0.4, min(2.0, value * zoom))
+ zoom = liveZoom
+ }
+ .onEnded { _ in liveZoom = zoom }
+ )
+ #endif
+ }
+ }
+ .navigationTitle("Blocks · \(scenarioName)")
+ .onAppear {
+ UndoBroker.shared.onSnapshot = { snap in
+ undoStack.append(snap)
+ if undoStack.count > 100 { undoStack.removeFirst(undoStack.count - 100) }
+ redoStack.removeAll()
+ }
+ }
+ .onDisappear { UndoBroker.shared.onSnapshot = nil }
+ .task { await load() }
+ .focusable()
+ #if os(macOS)
+ .onKeyPress(keys: [.delete, .init("\u{7F}")]) { _ in
+ deleteSelection()
+ return .handled
+ }
+ #endif
+ .toolbar { toolbarItems }
+ }
+
+ @ToolbarContentBuilder private var toolbarItems: some ToolbarContent {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button { undo() } label: { Image(systemName: "arrow.uturn.backward") }
+ .keyboardShortcut("z", modifiers: .command)
+ .disabled(undoStack.isEmpty)
+ Button { redo() } label: { Image(systemName: "arrow.uturn.forward") }
+ .keyboardShortcut("z", modifiers: [.command, .shift])
+ .disabled(redoStack.isEmpty)
+ Button { zoom = max(0.4, zoom - 0.1) } label: { Image(systemName: "minus.magnifyingglass") }
+ .keyboardShortcut("-", modifiers: .command)
+ Text("\(Int(zoom*100))%").font(.caption.monospacedDigit()).frame(width: 44)
+ Button { zoom = min(2.0, zoom + 0.1) } label: { Image(systemName: "plus.magnifyingglass") }
+ .keyboardShortcut("=", modifiers: .command)
+ Button { zoom = 1.0 } label: { Image(systemName: "1.magnifyingglass") }
+ .keyboardShortcut("0", modifiers: .command)
+ }
+ }
+
+ private var toolbar: some View {
+ HStack(spacing: 8) {
+ Text("Scénario: \(scenarioName)").font(.caption.monospaced()).foregroundStyle(.secondary)
+ if dirty { Label("Modifié", systemImage: "pencil.circle").foregroundStyle(.orange) }
+ if !selection.isEmpty {
+ Text("· \(selection.count) sélectionné(s)").font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ if let message { Text(message).font(.caption).foregroundStyle(.green) }
+ if let error { Text(error).font(.caption).foregroundStyle(.red).lineLimit(1) }
+ Button {
+ Task { await compile() }
+ } label: {
+ Label(compiling ? "…" : "Compiler en IR", systemImage: "hammer")
+ }
+ .disabled(compiling || document.nodes.isEmpty)
+ Button {
+ snapshotForUndo()
+ document = BlocksDocument(); selection.removeAll(); dirty = true
+ } label: { Label("Vider", systemImage: "trash") }
+ Button {
+ Task { await save() }
+ } label: { Label(saving ? "…" : "Enregistrer", systemImage: "square.and.arrow.down") }
+ .buttonStyle(.borderedProminent)
+ .disabled(saving || !dirty)
+ }
+ .padding(8)
+ }
+
+ // MARK: actions
+
+ private func addBlock(_ kind: BlockKind) {
+ snapshotForUndo()
+ let spec = BlockCatalog.spec(kind)
+ let pos = nextSpawnPosition()
+ let node = BlockNode(kind: kind, position: pos, params: spec.defaultParams())
+ document.append(node)
+ selection = [node.id]
+ dirty = true
+ }
+
+ private func nextSpawnPosition() -> CGPoint {
+ let baseX: CGFloat = 80
+ let baseY: CGFloat = 60
+ let cols = document.nodes.count / 8
+ return CGPoint(x: baseX + CGFloat(cols) * (blockWidth + 40),
+ y: baseY + CGFloat(document.nodes.count % 8) * 80)
+ }
+
+ private func deleteSelection() {
+ guard !selection.isEmpty else { return }
+ snapshotForUndo()
+ for id in selection { document.remove(id) }
+ selection.removeAll()
+ dirty = true
+ }
+
+ // MARK: undo
+
+ private func snapshotForUndo() {
+ undoStack.append(document)
+ if undoStack.count > 100 { undoStack.removeFirst(undoStack.count - 100) }
+ redoStack.removeAll()
+ }
+
+ private func undo() {
+ guard let prev = undoStack.popLast() else { return }
+ redoStack.append(document)
+ document = prev
+ dirty = true
+ }
+
+ private func redo() {
+ guard let next = redoStack.popLast() else { return }
+ undoStack.append(document)
+ document = next
+ dirty = true
+ }
+
+ // MARK: gateway
+
+ private func load() async {
+ do {
+ let detail = try await session.api.loadScenario(name: scenarioName)
+ if detail.yaml.contains("blocks_studio_version") {
+ document = try BlocksYAML.decode(detail.yaml)
+ } else {
+ document = BlocksDocument()
+ message = "Scénario non blocky — éditeur vide. Sauvegarder l'écrasera."
+ }
+ dirty = false
+ undoStack.removeAll(); redoStack.removeAll()
+ error = nil
+ } catch let err {
+ error = err.localizedDescription
+ }
+ }
+
+ private func save() async {
+ saving = true; defer { saving = false }
+ let yaml = BlocksYAML.encode(document)
+ do {
+ _ = try await session.api.saveScenario(name: scenarioName, yaml: yaml)
+ dirty = false
+ error = nil
+ message = "Enregistré (\(document.nodes.count) blocs)"
+ } catch let err {
+ error = err.localizedDescription
+ message = nil
+ }
+ }
+
+ private func compile() async {
+ compiling = true; defer { compiling = false }
+ do {
+ let resp = try await session.api.compileScenario(name: scenarioName)
+ message = "Compilé: \(resp.steps_count) steps, entry=\(resp.entry_step_id ?? "—")"
+ error = nil
+ } catch let err {
+ error = "Compile: \(err.localizedDescription)"
+ message = nil
+ }
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Blocks/BlocksYAML.swift b/apps/zacus-hub/Sources/Studio/Blocks/BlocksYAML.swift
new file mode 100644
index 0000000..4288279
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Blocks/BlocksYAML.swift
@@ -0,0 +1,218 @@
+import Foundation
+
+/// v2 round-trip codec: BlocksDocument ↔ flat-nodes YAML.
+///
+/// v1 was a chain-centric layout that silently dropped slot child chains.
+/// v2 emits every node as a flat list with `next:` and `slots:` references,
+/// so any graph topology round-trips losslessly.
+enum BlocksYAML {
+
+ static let currentVersion = 2
+
+ // MARK: encode
+
+ static func encode(_ doc: BlocksDocument) -> String {
+ var out = "blocks_studio_version: \(currentVersion)\nnodes:\n"
+ if doc.nodes.isEmpty { out += " []\n"; return out }
+ for node in doc.nodes {
+ out += " - id: \"\(node.id.uuidString)\"\n"
+ out += " kind: \(node.kind.rawValue)\n"
+ out += " position: [\(Int(node.position.x)), \(Int(node.position.y))]\n"
+ if let nextID = node.nextID {
+ out += " next: \"\(nextID.uuidString)\"\n"
+ }
+ if !node.params.isEmpty {
+ out += " params:\n"
+ for key in node.params.keys.sorted() {
+ out += " \(key): \(yamlScalar(node.params[key] ?? ""))\n"
+ }
+ }
+ if !node.slots.isEmpty {
+ out += " slots:\n"
+ for key in node.slots.keys.sorted() {
+ if let head = node.slots[key] {
+ out += " \(key): \"\(head.uuidString)\"\n"
+ }
+ }
+ }
+ }
+ return out
+ }
+
+ private static func yamlScalar(_ value: String) -> String {
+ if value.isEmpty { return "\"\"" }
+ if value.contains("\n") {
+ let lines = value.split(separator: "\n", omittingEmptySubsequences: false)
+ return "|\n" + lines.map { " \($0)" }.joined(separator: "\n")
+ }
+ // Always quote — keeps round-trip safe across YAML type inference.
+ let escaped = value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return "\"\(escaped)\""
+ }
+
+ // MARK: decode
+
+ enum DecodeError: Error, LocalizedError {
+ case notBlocksDocument
+ case unsupportedVersion(Int)
+ case malformed(String)
+ var errorDescription: String? {
+ switch self {
+ case .notBlocksDocument: return "YAML ne contient pas un document blocks_studio_version."
+ case .unsupportedVersion(let v): return "blocks_studio_version \(v) non supporté (attendu: 1 ou 2)."
+ case .malformed(let m): return "YAML malformé: \(m)"
+ }
+ }
+ }
+
+ /// Single-purpose tolerant parser. Recognises v1 (legacy chains/sequence) and v2 (flat nodes).
+ static func decode(_ text: String) throws -> BlocksDocument {
+ guard text.contains("blocks_studio_version") else { throw DecodeError.notBlocksDocument }
+ let version = parseVersion(text)
+ switch version {
+ case 1: return try decodeV1(text)
+ case 2: return try decodeV2(text)
+ default: throw DecodeError.unsupportedVersion(version)
+ }
+ }
+
+ private static func parseVersion(_ text: String) -> Int {
+ for raw in text.split(separator: "\n") {
+ let line = String(raw).trimmingCharacters(in: .whitespaces)
+ if line.hasPrefix("blocks_studio_version:") {
+ let value = line.dropFirst("blocks_studio_version:".count).trimmingCharacters(in: .whitespaces)
+ return Int(value) ?? 0
+ }
+ }
+ return 0
+ }
+
+ // MARK: v2 parser
+
+ private static func decodeV2(_ text: String) throws -> BlocksDocument {
+ var doc = BlocksDocument()
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var i = 0
+ while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("nodes:") { i += 1 }
+ i += 1
+ var current: BlockNode?
+ var inParams = false
+ var inSlots = false
+ func flush() { if let c = current { doc.nodes.append(c) }; current = nil; inParams = false; inSlots = false }
+ while i < lines.count {
+ let raw = lines[i]
+ let line = raw.trimmingCharacters(in: .whitespaces)
+ if line.hasPrefix("- id:") {
+ flush()
+ let idRaw = line.dropFirst(5).trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ let id = UUID(uuidString: String(idRaw)) ?? UUID()
+ current = BlockNode(id: id, kind: .sceneStart, position: .zero)
+ inParams = false; inSlots = false
+ } else if line.hasPrefix("kind:"), current != nil {
+ let raw = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
+ if let kind = BlockKind(rawValue: String(raw)) { current?.kind = kind }
+ inParams = false; inSlots = false
+ } else if line.hasPrefix("position:"), current != nil {
+ let nums = line.dropFirst(9).trimmingCharacters(in: .whitespaces)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "[] "))
+ .split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
+ if nums.count == 2 { current?.position = CGPoint(x: nums[0], y: nums[1]) }
+ inParams = false; inSlots = false
+ } else if line.hasPrefix("next:"), current != nil {
+ let raw = line.dropFirst(5).trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ current?.nextID = UUID(uuidString: String(raw))
+ inParams = false; inSlots = false
+ } else if line.hasPrefix("params:"), current != nil {
+ inParams = true; inSlots = false
+ } else if line.hasPrefix("slots:"), current != nil {
+ inSlots = true; inParams = false
+ } else if (inParams || inSlots), let colon = line.firstIndex(of: ":"), current != nil {
+ let key = String(line[.. BlocksDocument {
+ var doc = BlocksDocument()
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var i = 0
+ while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("chains:") { i += 1 }
+ i += 1
+ while i < lines.count {
+ let line = lines[i].trimmingCharacters(in: .whitespaces)
+ if line.hasPrefix("- root:") {
+ i += 1
+ var rootPos = CGPoint.zero
+ while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("sequence:") {
+ let l = lines[i].trimmingCharacters(in: .whitespaces)
+ if l.hasPrefix("position:") {
+ let nums = l.dropFirst(9).trimmingCharacters(in: .whitespaces)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "[] "))
+ .split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
+ if nums.count == 2 { rootPos = CGPoint(x: nums[0], y: nums[1]) }
+ }
+ i += 1
+ }
+ i += 1
+ var seq: [BlockNode] = []
+ var currentParams: [String: String] = [:]
+ var currentKind: BlockKind?
+ var currentID: UUID?
+ var inParams = false
+ while i < lines.count {
+ let l = lines[i].trimmingCharacters(in: .whitespaces)
+ if l.hasPrefix("- root:") || lines[i].hasPrefix(" - root:") { break }
+ if l.hasPrefix("- kind:") {
+ if let k = currentKind {
+ seq.append(BlockNode(id: currentID ?? UUID(), kind: k, position: .zero, params: currentParams))
+ }
+ currentParams = [:]; currentID = nil; inParams = false
+ let kindRaw = l.dropFirst(7).trimmingCharacters(in: .whitespaces)
+ currentKind = BlockKind(rawValue: String(kindRaw))
+ } else if l.hasPrefix("id:") && currentKind != nil {
+ let raw = l.dropFirst(3).trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ currentID = UUID(uuidString: String(raw))
+ } else if l.hasPrefix("params:") {
+ inParams = true
+ } else if inParams, let colon = l.firstIndex(of: ":") {
+ let key = String(l[.. = []
+ @State private var strategy: String = "auto"
+ @State private var results: [String: FlashResult] = [:]
+ @State private var running: Set = []
+ @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)
+ }
+ }
+}
diff --git a/apps/zacus-hub/Sources/Studio/Scratch/ScratchEditorView.swift b/apps/zacus-hub/Sources/Studio/Scratch/ScratchEditorView.swift
new file mode 100644
index 0000000..043f7c5
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/Scratch/ScratchEditorView.swift
@@ -0,0 +1,225 @@
+import SwiftUI
+import WebKit
+#if os(iOS)
+import UniformTypeIdentifiers
+#endif
+
+/// Blockly-in-WKWebView Scratch-like editor. Loads `Resources/blockly/editor.html`
+/// (which pulls Blockly + JSZip from CDN) and bridges save / sb3 export to the gateway.
+struct ScratchEditorView: View {
+ @EnvironmentObject var session: HubSession
+ let scenarioName: String
+
+ @State private var bridge = WebBridge()
+ @State private var status: String = "chargement…"
+ @State private var lastError: String?
+ @State private var isReady = false
+ @State private var saving = false
+ @State private var showFlasher = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ toolbar
+ Divider()
+ BlocklyWebView(bridge: bridge,
+ onMessage: handle,
+ onReady: { isReady = true; Task { await loadFromGateway() } })
+ }
+ .navigationTitle("Scratch · \(scenarioName)")
+ }
+
+ private var toolbar: some View {
+ HStack(spacing: 8) {
+ Text("Scénario: \(scenarioName)").font(.caption.monospaced()).foregroundStyle(.secondary)
+ Spacer()
+ Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ if let lastError {
+ Text(lastError).font(.caption).foregroundStyle(.red).lineLimit(1)
+ }
+ Button {
+ Task { await loadFromGateway() }
+ } label: { Label("Recharger", systemImage: "arrow.clockwise") }
+ .disabled(!isReady)
+ Button {
+ showFlasher = true
+ } label: { Label("Flasher", systemImage: "bolt.fill") }
+ .buttonStyle(.borderedProminent)
+ .disabled(!isReady)
+ }
+ .padding(8)
+ .sheet(isPresented: $showFlasher) {
+ FlasherSheet(scenarioName: scenarioName).environmentObject(session)
+ }
+ }
+
+ // MARK: bridge handling
+
+ private func handle(_ payload: [String: Any]) {
+ guard let op = payload["op"] as? String else { return }
+ switch op {
+ case "ready":
+ status = "prêt"
+ case "save":
+ if let yaml = payload["yaml"] as? String {
+ Task { await pushToGateway(yaml: yaml) }
+ }
+ case "exportSB3":
+ if let b64 = payload["base64"] as? String, let data = Data(base64Encoded: b64) {
+ Task { await saveSB3Locally(data: data) }
+ }
+ case "error":
+ lastError = payload["message"] as? String
+ default:
+ break
+ }
+ }
+
+ private func loadFromGateway() async {
+ do {
+ let detail = try await session.api.loadScenario(name: scenarioName)
+ status = "chargé"
+ lastError = nil
+ await MainActor.run {
+ let safeYaml = jsString(detail.yaml)
+ bridge.run("window.zacus && window.zacus.loadYAML(\(safeYaml))")
+ }
+ } catch let err {
+ lastError = err.localizedDescription
+ }
+ }
+
+ private func pushToGateway(yaml: String) async {
+ saving = true; defer { saving = false }
+ do {
+ _ = try await session.api.saveScenario(name: scenarioName, yaml: yaml)
+ status = "enregistré · \(timestamp())"
+ lastError = nil
+ } catch let err {
+ lastError = err.localizedDescription
+ }
+ }
+
+ private func saveSB3Locally(data: Data) async {
+ let fm = FileManager.default
+ let baseURL: URL
+ #if os(macOS)
+ baseURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
+ #else
+ baseURL = fm.urls(for: .documentDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
+ #endif
+ let file = baseURL.appendingPathComponent("\(scenarioName).sb3")
+ do {
+ try data.write(to: file)
+ status = "exporté vers \(file.lastPathComponent)"
+ #if os(macOS)
+ NSWorkspace.shared.activateFileViewerSelecting([file])
+ #endif
+ } catch {
+ lastError = "écriture .sb3: \(error.localizedDescription)"
+ }
+ }
+
+ private func jsString(_ s: String) -> String {
+ // wrap as a JSON string so embedded quotes/newlines survive JS eval
+ guard let data = try? JSONSerialization.data(withJSONObject: [s]),
+ let text = String(data: data, encoding: .utf8) else {
+ return "\"\""
+ }
+ // strip [" ... "] → keep the inner JSON string literal
+ let trimmed = text.dropFirst().dropLast()
+ return String(trimmed)
+ }
+
+ private func timestamp() -> String {
+ let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: Date())
+ }
+}
+
+// MARK: - Bridge container
+
+final class WebBridge {
+ weak var webView: WKWebView?
+ func run(_ js: String) {
+ webView?.evaluateJavaScript(js, completionHandler: nil)
+ }
+}
+
+// MARK: - WKWebView SwiftUI wrapper
+
+#if os(macOS)
+struct BlocklyWebView: NSViewRepresentable {
+ let bridge: WebBridge
+ let onMessage: ([String: Any]) -> Void
+ let onReady: () -> Void
+
+ func makeNSView(context: Context) -> WKWebView {
+ let webView = makeWebView(coordinator: context.coordinator)
+ bridge.webView = webView
+ return webView
+ }
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+ func makeCoordinator() -> Coordinator { Coordinator(onMessage: onMessage, onReady: onReady) }
+ final class Coordinator: NSObject, WKScriptMessageHandler {
+ let onMessage: ([String: Any]) -> Void
+ let onReady: () -> Void
+ init(onMessage: @escaping ([String: Any]) -> Void, onReady: @escaping () -> Void) {
+ self.onMessage = onMessage; self.onReady = onReady
+ }
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let body = message.body as? [String: Any] else { return }
+ DispatchQueue.main.async {
+ self.onMessage(body)
+ if (body["op"] as? String) == "ready" { self.onReady() }
+ }
+ }
+ }
+}
+#else
+struct BlocklyWebView: UIViewRepresentable {
+ let bridge: WebBridge
+ let onMessage: ([String: Any]) -> Void
+ let onReady: () -> Void
+
+ func makeUIView(context: Context) -> WKWebView {
+ let webView = makeWebView(coordinator: context.coordinator)
+ bridge.webView = webView
+ return webView
+ }
+ func updateUIView(_ uiView: WKWebView, context: Context) {}
+ func makeCoordinator() -> Coordinator { Coordinator(onMessage: onMessage, onReady: onReady) }
+ final class Coordinator: NSObject, WKScriptMessageHandler {
+ let onMessage: ([String: Any]) -> Void
+ let onReady: () -> Void
+ init(onMessage: @escaping ([String: Any]) -> Void, onReady: @escaping () -> Void) {
+ self.onMessage = onMessage; self.onReady = onReady
+ }
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let body = message.body as? [String: Any] else { return }
+ DispatchQueue.main.async {
+ self.onMessage(body)
+ if (body["op"] as? String) == "ready" { self.onReady() }
+ }
+ }
+ }
+}
+#endif
+
+private func makeWebView(coordinator: NSObject & WKScriptMessageHandler) -> WKWebView {
+ let cfg = WKWebViewConfiguration()
+ let ucc = WKUserContentController()
+ ucc.add(coordinator, name: "zacus")
+ cfg.userContentController = ucc
+ let pref = WKPreferences()
+ pref.javaScriptCanOpenWindowsAutomatically = false
+ cfg.preferences = pref
+ let webView = WKWebView(frame: .zero, configuration: cfg)
+ if let url = Bundle.main.url(forResource: "editor", withExtension: "html", subdirectory: "blockly") {
+ webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
+ } else if let url = Bundle.main.url(forResource: "editor", withExtension: "html") {
+ webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
+ }
+ #if os(macOS)
+ webView.setValue(false, forKey: "drawsBackground")
+ #endif
+ return webView
+}
diff --git a/apps/zacus-hub/Sources/Studio/StudioView.swift b/apps/zacus-hub/Sources/Studio/StudioView.swift
new file mode 100644
index 0000000..b8e9a0e
--- /dev/null
+++ b/apps/zacus-hub/Sources/Studio/StudioView.swift
@@ -0,0 +1,195 @@
+import SwiftUI
+
+enum StudioMode: String, CaseIterable, Identifiable {
+ case scratch, yaml
+ var id: String { rawValue }
+ var label: String {
+ switch self { case .yaml: "YAML"; case .scratch: "Scratch" }
+ }
+ var systemImage: String {
+ switch self { case .yaml: "doc.text"; case .scratch: "puzzlepiece.extension" }
+ }
+}
+
+struct StudioView: View {
+ @EnvironmentObject var session: HubSession
+ @State private var scenarios: [ScenarioMeta] = []
+ @State private var selection: ScenarioMeta?
+ @State private var loadedName: String?
+ @State private var yamlText: String = ""
+ @State private var validation: ValidationResult?
+ @State private var saving = false
+ @State private var validating = false
+ @State private var error: String?
+ @State private var dirty = false
+ @State private var mode: StudioMode = .scratch
+
+ var body: some View {
+ #if os(macOS)
+ HSplitView {
+ list.frame(minWidth: 240)
+ editor
+ }
+ .navigationTitle("Studio")
+ .task { await loadList() }
+ #else
+ NavigationStack {
+ list
+ .navigationTitle("Studio")
+ .navigationDestination(item: $selection) { meta in
+ editor.navigationTitle(meta.name)
+ }
+ }
+ .task { await loadList() }
+ #endif
+ }
+
+ private var list: some View {
+ List(selection: $selection) {
+ Section("Scénarios") {
+ ForEach(scenarios) { meta in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(meta.name).font(.body.monospaced())
+ Text("\(meta.size) o · \(modifiedDate(meta.modified))")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .tag(meta)
+ }
+ }
+ if let error {
+ Section { Text(error).font(.caption).foregroundStyle(.red) }
+ }
+ }
+ .refreshable { await loadList() }
+ .onChange(of: selection) { _, new in
+ guard let new else { return }
+ Task { await loadDetail(name: new.name) }
+ }
+ }
+
+ @ViewBuilder private var editor: some View {
+ if let name = loadedName {
+ VStack(spacing: 0) {
+ modePicker
+ Divider()
+ switch mode {
+ case .yaml:
+ VStack(spacing: 0) {
+ toolbar
+ Divider()
+ TextEditor(text: $yamlText)
+ .font(.system(.callout, design: .monospaced))
+ .scrollContentBackground(.hidden)
+ .background(Color.gray.opacity(0.05))
+ .onChange(of: yamlText) { _, _ in dirty = true }
+ if let validation { validationBanner(validation) }
+ }
+ case .scratch:
+ ScratchEditorView(scenarioName: name)
+ .environmentObject(session)
+ }
+ }
+ } else if selection != nil {
+ ProgressView()
+ } else {
+ ContentUnavailableView("Aucune sélection", systemImage: "doc.text", description: Text("Choisis un scénario à gauche."))
+ }
+ }
+
+ private var modePicker: some View {
+ HStack {
+ Picker("Mode", selection: $mode) {
+ ForEach(StudioMode.allCases) { m in
+ Label(m.label, systemImage: m.systemImage).tag(m)
+ }
+ }
+ .pickerStyle(.segmented)
+ .frame(maxWidth: 280)
+ Spacer()
+ }
+ .padding(8)
+ }
+
+ private var toolbar: some View {
+ HStack(spacing: 8) {
+ if dirty { Label("Modifié", systemImage: "pencil.circle").foregroundStyle(.orange) }
+ Spacer()
+ Button {
+ Task { await validate() }
+ } label: {
+ Label(validating ? "…" : "Valider", systemImage: "checkmark.shield")
+ }
+ .disabled(validating)
+ Button {
+ Task { await save() }
+ } label: {
+ Label(saving ? "…" : "Enregistrer", systemImage: "square.and.arrow.down")
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(saving || !dirty)
+ }
+ .padding(8)
+ }
+
+ private func validationBanner(_ v: ValidationResult) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Image(systemName: v.ok ? "checkmark.circle.fill" : "xmark.octagon.fill")
+ Text(v.ok ? "YAML valide" : "Erreurs détectées").font(.subheadline.bold())
+ }
+ .foregroundStyle(v.ok ? .green : .red)
+ ForEach(v.errors, id: \.self) { e in Text("• \(e)").font(.caption).foregroundStyle(.red) }
+ ForEach(v.warnings, id: \.self) { w in Text("⚠︎ \(w)").font(.caption).foregroundStyle(.orange) }
+ if !v.top_level_keys.isEmpty {
+ Text("Clés: \(v.top_level_keys.joined(separator: ", "))").font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.thinMaterial)
+ }
+
+ private func loadList() async {
+ do {
+ scenarios = try await session.api.listScenarios()
+ error = nil
+ } catch { self.error = error.localizedDescription }
+ }
+
+ private func loadDetail(name: String) async {
+ do {
+ let detail = try await session.api.loadScenario(name: name)
+ loadedName = detail.name
+ yamlText = detail.yaml
+ validation = nil
+ dirty = false
+ error = nil
+ } catch { self.error = error.localizedDescription }
+ }
+
+ private func validate() async {
+ guard let name = loadedName else { return }
+ validating = true
+ defer { validating = false }
+ do {
+ validation = try await session.api.validateScenario(name: name, yaml: yamlText)
+ } catch let err { error = err.localizedDescription }
+ }
+
+ private func save() async {
+ guard let name = loadedName else { return }
+ saving = true
+ defer { saving = false }
+ do {
+ validation = try await session.api.saveScenario(name: name, yaml: yamlText)
+ dirty = false
+ } catch let err { error = err.localizedDescription }
+ }
+
+ private func modifiedDate(_ epoch: Double) -> String {
+ let date = Date(timeIntervalSince1970: epoch)
+ let f = DateFormatter(); f.dateStyle = .short; f.timeStyle = .short
+ return f.string(from: date)
+ }
+}
diff --git a/apps/zacus-hub/project.yml b/apps/zacus-hub/project.yml
new file mode 100644
index 0000000..b37c204
--- /dev/null
+++ b/apps/zacus-hub/project.yml
@@ -0,0 +1,66 @@
+name: ZacusHub
+options:
+ bundleIdPrefix: cc.saillant.zacus
+ deploymentTarget:
+ iOS: "17.0"
+ macOS: "14.0"
+ createIntermediateGroups: true
+
+settings:
+ base:
+ SWIFT_VERSION: "5.10"
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ DEVELOPMENT_TEAM: "JRLQM7V3V5"
+ CODE_SIGN_STYLE: Automatic
+ SWIFT_STRICT_CONCURRENCY: complete
+
+targets:
+ ZacusHub:
+ type: application
+ supportedDestinations: [iOS, macOS, iPadOS]
+ sources:
+ - path: Sources
+ - path: Resources/blockly
+ type: folder
+ buildPhase: resources
+ info:
+ path: Resources/Info.plist
+ properties:
+ CFBundleDisplayName: Zacus Hub
+ ITSAppUsesNonExemptEncryption: false
+ NSMicrophoneUsageDescription: Push-to-talk avec Zacus dans le mode Companion.
+ NSCameraUsageDescription: Scanner les QR codes du jeu.
+ NSLocalNetworkUsageDescription: Communication avec le gateway Zacus sur le tailnet.
+ NSAppTransportSecurity:
+ NSAllowsArbitraryLoads: true
+ NSExceptionDomains:
+ "100.78.191.52":
+ NSExceptionAllowsInsecureHTTPLoads: true
+ NSTemporaryExceptionAllowsInsecureHTTPLoads: true
+ NSExceptionMinimumTLSVersion: TLSv1.0
+ "100.116.92.12":
+ NSExceptionAllowsInsecureHTTPLoads: true
+ NSTemporaryExceptionAllowsInsecureHTTPLoads: true
+ "100.112.121.126":
+ NSExceptionAllowsInsecureHTTPLoads: true
+ NSTemporaryExceptionAllowsInsecureHTTPLoads: true
+ electron-server:
+ NSExceptionAllowsInsecureHTTPLoads: true
+ NSIncludesSubdomains: true
+ macm1:
+ NSExceptionAllowsInsecureHTTPLoads: true
+ studio:
+ NSExceptionAllowsInsecureHTTPLoads: true
+ entitlements:
+ path: Resources/ZacusHub.entitlements
+ properties:
+ com.apple.security.app-sandbox: true
+ com.apple.security.network.client: true
+ com.apple.security.device.audio-input: true
+ com.apple.security.device.camera: true
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: cc.saillant.zacus.hub
+ GENERATE_INFOPLIST_FILE: NO
+ ENABLE_PREVIEWS: YES