feat(apps): zacus-hub SwiftUI hub (macOS + iOS)

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.
This commit is contained in:
L'électron rare
2026-05-24 10:51:26 +02:00
parent 035e01bb41
commit 3babe2b3ab
27 changed files with 3924 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
ZacusHub.xcodeproj/
.build/
DerivedData/
*.xcuserstate
xcuserdata/
.DS_Store
+63
View File
@@ -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.
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Zacus Hub</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>100.112.121.126</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>100.116.92.12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>100.78.191.52</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>electron-server</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>macm1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>studio</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSCameraUsageDescription</key>
<string>Scanner les QR codes du jeu.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Communication avec le gateway Zacus sur le tailnet.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Push-to-talk avec Zacus dans le mode Companion.</string>
</dict>
</plist>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zacus Blocks (Scratch-like)</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif; }
#blocklyDiv { position: absolute; top: 40px; left: 0; right: 0; bottom: 0; }
#toolbar { position: absolute; top: 0; left: 0; right: 0; height: 40px;
background: rgba(245,245,247,0.94); display: flex; align-items: center;
padding: 0 12px; gap: 8px; border-bottom: 1px solid rgba(0,0,0,0.12);
font-size: 13px; backdrop-filter: saturate(180%) blur(8px); }
#toolbar button { font: inherit; padding: 4px 10px; border-radius: 6px;
border: 1px solid rgba(0,0,0,0.12); background: white; cursor: pointer; }
#toolbar button:hover { background: #efeff4; }
#toolbar .spacer { flex: 1; }
#toolbar .status { font-size: 11px; color: #666; }
input[type=file] { display: none; }
</style>
<script src="https://unpkg.com/blockly@11.2.2/blockly_compressed.js"></script>
<script src="https://unpkg.com/blockly@11.2.2/blocks_compressed.js"></script>
<script src="https://unpkg.com/blockly@11.2.2/msg/fr.js"></script>
<script src="https://unpkg.com/jszip@3.10.1/dist/jszip.min.js"></script>
</head>
<body>
<div id="toolbar">
<button onclick="window.zacus.exportSB3()">⬇ .sb3</button>
<label class="filebtn">
<input type="file" id="sb3picker" accept=".sb3" onchange="window.zacus.importSB3(this.files[0])">
<button onclick="document.getElementById('sb3picker').click()">⬆ .sb3</button>
</label>
<button onclick="window.zacus.save()">💾 Enregistrer (gateway)</button>
<span class="spacer"></span>
<span class="status" id="status">prêt</span>
</div>
<div id="blocklyDiv"></div>
<script src="zacus_blocks.js"></script>
<script src="zacus_codec.js"></script>
<script>
(function() {
const TOOLBOX = {
kind: "categoryToolbox",
contents: [
{ kind: "category", name: "Scènes", colour: "#5085F2", contents: [
{ kind: "block", type: "zacus_sceneStart" },
{ kind: "block", type: "zacus_sceneEnd" },
{ kind: "block", type: "zacus_sceneGoto" },
{ kind: "block", type: "zacus_sceneBranch" },
]},
{ kind: "category", name: "NPC (Zacus)", colour: "#D85B73", contents: [
{ kind: "block", type: "zacus_npcSay" },
{ kind: "block", type: "zacus_npcWaitResponse" },
{ kind: "block", type: "zacus_npcIntentMatch" },
]},
{ kind: "category", name: "Audio", colour: "#A772D9", contents: [
{ kind: "block", type: "zacus_hwSoundPlay" },
{ kind: "block", type: "zacus_hwAudioStop" },
{ kind: "block", type: "zacus_hwAudioVolume" },
]},
{ kind: "category", name: "Affichage LCD", colour: "#338CBF", contents: [
{ kind: "block", type: "zacus_hwLCDText" },
{ kind: "block", type: "zacus_hwLCDClear" },
{ kind: "block", type: "zacus_hwLCDImage" },
{ kind: "block", type: "zacus_hwLCDTouchWait" },
]},
{ kind: "category", name: "Hardware ESP32", colour: "#2EAD8C", contents: [
{ kind: "block", type: "zacus_hwServo" },
{ kind: "block", type: "zacus_hwReadQR" },
{ kind: "block", type: "zacus_hwLEDPattern" },
{ kind: "block", type: "zacus_hwBuzzerTone" },
{ kind: "block", type: "zacus_hwRelay" },
{ kind: "block", type: "zacus_hwSensorRead" },
{ kind: "block", type: "zacus_hwButtonWait" },
]},
{ kind: "category", name: "ESP-NOW", colour: "#F28033", contents: [
{ kind: "block", type: "zacus_espnowRegisterPeer" },
{ kind: "block", type: "zacus_espnowSend" },
{ kind: "block", type: "zacus_espnowBroadcast" },
{ kind: "block", type: "zacus_espnowWait" },
]},
{ kind: "category", name: "ESP32-S3-BOX-3", colour: "#8C5A33", contents: [
{ kind: "block", type: "zacus_boxIMUShake" },
{ kind: "block", type: "zacus_boxIRSend" },
]},
{ kind: "category", name: "M5Stack / M5StickC", colour: "#4D73A6", contents: [
{ kind: "block", type: "zacus_m5Beep" },
{ kind: "block", type: "zacus_m5LCDText" },
{ kind: "block", type: "zacus_m5ButtonAB" },
{ kind: "block", type: "zacus_m5RGBLed" },
{ kind: "block", type: "zacus_m5IMUShake" },
]},
{ kind: "category", name: "PLIP téléphone", colour: "#C74D8C", contents: [
{ kind: "block", type: "zacus_plipRing" },
{ kind: "block", type: "zacus_plipPickupWait" },
]},
{ kind: "category", name: "Logique", colour: "#F2A633", contents: [
{ kind: "block", type: "zacus_logicIf" },
{ kind: "block", type: "zacus_logicTimer" },
{ kind: "block", type: "zacus_logicScore" },
{ kind: "block", type: "zacus_logicSetVar" },
]},
]
};
const workspace = Blockly.inject(document.getElementById("blocklyDiv"), {
toolbox: TOOLBOX,
renderer: "zelos",
theme: Blockly.Themes.Zelos || undefined,
grid: { spacing: 24, length: 3, colour: "#ddd", snap: true },
zoom: { controls: true, wheel: true, startScale: 1.0, maxScale: 2.0, minScale: 0.4, scaleSpeed: 1.1 },
trashcan: true,
move: { scrollbars: true, drag: true, wheel: false },
});
const status = document.getElementById("status");
let dirty = false;
workspace.addChangeListener(function(ev) {
if (ev.isUiEvent) return;
if (ev.type === Blockly.Events.FINISHED_LOADING) return;
dirty = true;
status.textContent = "modifié";
});
function postToHost(payload) {
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.zacus) {
window.webkit.messageHandlers.zacus.postMessage(payload);
}
}
window.zacus = {
loadYAML: function(yamlText) {
try {
const xml = window.ZacusCodec.yamlToWorkspaceXml(yamlText);
workspace.clear();
if (xml) {
const dom = Blockly.utils.xml.textToDom(xml);
Blockly.Xml.domToWorkspace(dom, workspace);
}
dirty = false;
status.textContent = "chargé";
} catch (e) {
status.textContent = "erreur load: " + e.message;
postToHost({ op: "error", message: String(e) });
}
},
save: function() {
try {
const yaml = window.ZacusCodec.workspaceToYaml(workspace);
postToHost({ op: "save", yaml: yaml });
dirty = false;
status.textContent = "enregistré";
} catch (e) {
status.textContent = "erreur save: " + e.message;
postToHost({ op: "error", message: String(e) });
}
},
exportSB3: async function() {
try {
const blob = await window.ZacusCodec.workspaceToSB3(workspace);
// post as base64 to host for it to save
const reader = new FileReader();
reader.onload = () => {
const b64 = reader.result.split(",")[1];
postToHost({ op: "exportSB3", base64: b64 });
};
reader.readAsDataURL(blob);
status.textContent = ".sb3 prêt";
} catch (e) {
status.textContent = "erreur sb3: " + e.message;
postToHost({ op: "error", message: String(e) });
}
},
importSB3: async function(file) {
if (!file) return;
try {
const data = await file.arrayBuffer();
const xml = await window.ZacusCodec.sb3ToWorkspaceXml(data);
workspace.clear();
if (xml) {
const dom = Blockly.utils.xml.textToDom(xml);
Blockly.Xml.domToWorkspace(dom, workspace);
}
dirty = true;
status.textContent = "importé " + file.name;
} catch (e) {
status.textContent = "erreur import: " + e.message;
postToHost({ op: "error", message: String(e) });
}
}
};
postToHost({ op: "ready" });
})();
</script>
</body>
</html>
@@ -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"],
};
@@ -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.<kind> <field1> <field2>...`. 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(`<block type="zacus_${n.kind}" id="${n.id}" x="${n.position[0]}" y="${n.position[1]}">`);
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(`<field name="${f}">${escapeXml(v)}</field>`);
}
for (const slotName of Object.keys(n.slots || {})) {
const headId = n.slots[slotName];
const head = byId[headId];
if (head) {
lines.push(`<statement name="${slotName}">${chainXml(head)}</statement>`);
}
}
const nxt = n.next ? byId[n.next] : null;
if (nxt) lines.push(`<next>${blockXml(nxt)}</next>`);
lines.push(`</block>`);
return lines.join("");
}
function chainXml(head) { return blockXml(head); }
const xml = `<xml xmlns="https://developers.google.com/blockly/xml">` +
roots.map(blockXml).join("") + `</xml>`;
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;");
}
// ---------- .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.<kind> ...
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,
};
})();
+69
View File
@@ -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<HubMode>) { self.init(get: { source.wrappedValue }, set: { source.wrappedValue = $0 }) }
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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 }
}
}
@@ -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())
}
}
@@ -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
}
}
+244
View File
@@ -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<T: Decodable>(_ path: String) async throws -> T {
try await run(makeRequest(path, method: "GET"))
}
private func post<T: Decodable>(_ 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<T: Decodable>(_ 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)) }
}
@@ -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<Void, Never>?
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] = []
}
@@ -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
}
}
@@ -0,0 +1,294 @@
import SwiftUI
#if canImport(UniformTypeIdentifiers)
import UniformTypeIdentifiers
#endif
struct BlockCanvasView: View {
@Binding var document: BlocksDocument
@Binding var selection: Set<UUID>
@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<BlockNode>? {
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<UUID>()
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) }
}
@@ -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 }) }
}
}
@@ -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<UUID>()
while let id = cur, !seen.contains(id), let n = node(id) {
out.append(n)
seen.insert(id)
cur = n.nextID
}
return out
}
}
@@ -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
}
}
@@ -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<String>(
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)
}
@@ -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<UUID> = []
@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
}
}
}
@@ -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[..<colon]).trimmingCharacters(in: .whitespaces)
let value = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
if !key.isEmpty {
if inParams { current?.params[key] = value }
if inSlots, let uuid = UUID(uuidString: value) { current?.slots[key] = uuid }
}
}
i += 1
}
flush()
if doc.nodes.isEmpty { throw DecodeError.malformed("no nodes parsed") }
return doc
}
// MARK: v1 legacy parser (kept for back-compat read; we re-save as v2)
private static func decodeV1(_ 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("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[..<colon]).trimmingCharacters(in: .whitespaces)
let value = String(l[l.index(after: colon)...]).trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
if !key.isEmpty { currentParams[key] = value }
}
i += 1
}
if let k = currentKind {
seq.append(BlockNode(id: currentID ?? UUID(), kind: k, position: .zero, params: currentParams))
}
var y: CGFloat = rootPos.y
let x: CGFloat = rootPos.x
var prev: UUID?
for var n in seq {
n.position = CGPoint(x: x, y: y)
y += estimatedHeight(of: n)
doc.nodes.append(n)
if let prev, let pIdx = doc.nodes.firstIndex(where: { $0.id == prev }) {
doc.nodes[pIdx].nextID = n.id
}
prev = n.id
}
continue
}
i += 1
}
return doc
}
}
@@ -0,0 +1,147 @@
import SwiftUI
struct FlasherSheet: View {
@EnvironmentObject var session: HubSession
@Environment(\.dismiss) private var dismiss
let scenarioName: String
@State private var boards: [BoardInfo] = []
@State private var selected: Set<String> = []
@State private var strategy: String = "auto"
@State private var results: [String: FlashResult] = [:]
@State private var running: Set<String> = []
@State private var error: String?
var body: some View {
NavigationStack {
Form {
Section("Scénario") {
Text(scenarioName).font(.body.monospaced())
Picker("Stratégie", selection: $strategy) {
Text("Auto (hot si IP, sinon cold)").tag("auto")
Text("Hot — POST IR via WiFi").tag("hot")
Text("Cold — rebuild + flash série").tag("cold")
}
.pickerStyle(.menu)
}
Section("Boards cibles") {
if boards.isEmpty {
Text("Aucun board déclaré — vérifie `tools/zacus-gateway/boards.yaml` sur electron-server.")
.font(.caption).foregroundStyle(.secondary)
}
ForEach(boards) { board in
boardRow(board)
}
}
if let error { Section { Text(error).foregroundStyle(.red).font(.caption) } }
if !results.isEmpty {
Section("Résultats") {
ForEach(Array(results.keys).sorted(), id: \.self) { name in
if let r = results[name] { resultBlock(r) }
}
}
}
}
.navigationTitle("Flasher")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Fermer") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Flasher") { Task { await flashAll() } }
.disabled(selected.isEmpty || !running.isEmpty)
}
}
.task { await loadBoards() }
}
#if os(macOS)
.frame(minWidth: 640, minHeight: 520)
#endif
}
private func boardRow(_ board: BoardInfo) -> some View {
HStack(alignment: .top) {
Toggle(isOn: Binding(
get: { selected.contains(board.name) },
set: { on in if on { selected.insert(board.name) } else { selected.remove(board.name) } }
)) {
VStack(alignment: .leading, spacing: 2) {
Text(board.label).font(.body)
HStack(spacing: 8) {
Text(board.type).font(.caption.monospaced()).foregroundStyle(.secondary)
if let ip = board.ip { Text(ip).font(.caption2.monospaced()).foregroundStyle(.secondary) }
else if !board.espnow_relay_peers.isEmpty {
Text("via ESP-NOW relay").font(.caption2).foregroundStyle(.orange)
} else {
Text("cold-flash uniquement").font(.caption2).foregroundStyle(.secondary)
}
}
if !board.espnow_relay_peers.isEmpty {
Text("Pousse en mesh: \(board.espnow_relay_peers.joined(separator: ", "))")
.font(.caption2).foregroundStyle(.blue)
}
}
}
Spacer()
if running.contains(board.name) {
ProgressView().scaleEffect(0.7)
}
}
}
private func resultBlock(_ r: FlashResult) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: r.ok ? "checkmark.circle.fill" : "exclamationmark.octagon.fill")
.foregroundStyle(r.ok ? .green : .red)
Text("\(r.board) · \(r.strategy_used)").font(.subheadline.bold())
}
ForEach(Array(r.steps.enumerated()), id: \.offset) { _, s in
HStack(alignment: .top) {
Text(statusGlyph(s.status)).foregroundStyle(statusColor(s.status))
Text(s.label).font(.caption.bold())
Text(s.detail).font(.caption).foregroundStyle(.secondary)
}
}
if let cmd = r.cold_command, !cmd.isEmpty {
Text("Commande locale:").font(.caption.bold()).padding(.top, 2)
Text(cmd)
.font(.caption.monospaced())
.textSelection(.enabled)
.padding(6)
.background(Color.black.opacity(0.08))
.cornerRadius(4)
}
if !r.relayed_to.isEmpty {
Text("Mesh: relayé vers \(r.relayed_to.joined(separator: ", "))")
.font(.caption).foregroundStyle(.blue)
}
}
.padding(.vertical, 4)
}
private func statusGlyph(_ s: String) -> String {
switch s { case "ok": ""; case "warn": ""; case "skip": ""; default: "" }
}
private func statusColor(_ s: String) -> Color {
switch s { case "ok": .green; case "warn": .orange; case "skip": .secondary; default: .red }
}
private func loadBoards() async {
do { boards = try await session.api.listBoards(); error = nil }
catch let err { error = err.localizedDescription }
}
private func flashAll() async {
for name in selected {
running.insert(name)
do {
let r = try await session.api.flashBoard(name, scenario: scenarioName, strategy: strategy)
results[name] = r
} catch let err {
results[name] = FlashResult(board: name, strategy_used: strategy, ok: false,
steps: [FlashStep(label: "request", status: "error", detail: err.localizedDescription)],
ir_path: nil, cold_command: nil, relayed_to: [])
}
running.remove(name)
}
}
}
@@ -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
}
@@ -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)
}
}
+66
View File
@@ -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