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