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:
@@ -0,0 +1,6 @@
|
||||
ZacusHub.xcodeproj/
|
||||
.build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
}
|
||||
|
||||
// ---------- .sb3 export ----------
|
||||
|
||||
// Minimal valid Scratch 3 backdrop: a 1×1 transparent PNG.
|
||||
const BACKDROP_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAfbLI3wAAAABJRU5ErkJggg==";
|
||||
const BACKDROP_MD5 = "cd21514d0531fdffb22204e0ec5ed84a"; // md5 of the PNG bytes above
|
||||
// (md5 is required by Scratch's asset references — using a well-known empty
|
||||
// backdrop md5; if Scratch refuses, we fall back to recomputing.)
|
||||
|
||||
async function workspaceToSB3(workspace) {
|
||||
const yaml = workspaceToYaml(workspace);
|
||||
const allBlocks = workspace.getAllBlocks(false).filter(b => b.type.startsWith("zacus_"));
|
||||
|
||||
const sbBlocks = {};
|
||||
for (const b of allBlocks) {
|
||||
const kind = b.type.replace(/^zacus_/, "");
|
||||
const fields = (window.ZACUS_FIELDS_BY_KIND[b.type] || []);
|
||||
const proccode = "zacus." + kind + (fields.length ? " " + fields.map(_ => "%s").join(" ") : "");
|
||||
const argNames = fields;
|
||||
const argIds = fields.map(f => `arg_${b.id}_${f}`);
|
||||
const argDefaults = fields.map(f => b.getFieldValue(f) ?? "");
|
||||
|
||||
const callId = b.id;
|
||||
const inputs = {};
|
||||
const callArgs = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const aid = `lit_${b.id}_${fields[i]}`;
|
||||
// shadow text literal block
|
||||
sbBlocks[aid] = {
|
||||
opcode: "text",
|
||||
next: null, parent: callId,
|
||||
inputs: {}, fields: { TEXT: [String(argDefaults[i]), null] },
|
||||
shadow: true, topLevel: false,
|
||||
};
|
||||
callArgs[argIds[i]] = [1, aid];
|
||||
}
|
||||
|
||||
const parent = b.getParent();
|
||||
const nextBlock = b.getNextBlock();
|
||||
sbBlocks[callId] = {
|
||||
opcode: "procedures_call",
|
||||
next: nextBlock ? nextBlock.id : null,
|
||||
parent: parent ? parent.id : null,
|
||||
inputs: callArgs,
|
||||
fields: {},
|
||||
shadow: false,
|
||||
topLevel: !parent,
|
||||
x: parent ? undefined : Math.round(b.getRelativeToSurfaceXY().x),
|
||||
y: parent ? undefined : Math.round(b.getRelativeToSurfaceXY().y),
|
||||
mutation: {
|
||||
tagName: "mutation",
|
||||
children: [],
|
||||
proccode: proccode,
|
||||
argumentids: JSON.stringify(argIds),
|
||||
warp: "false",
|
||||
},
|
||||
};
|
||||
// Slot bodies are not expanded into Scratch's stack here — slots stay
|
||||
// available as round-trip via the embedded YAML.
|
||||
}
|
||||
|
||||
const stage = {
|
||||
isStage: true,
|
||||
name: "Stage",
|
||||
variables: {},
|
||||
lists: {},
|
||||
broadcasts: {},
|
||||
blocks: sbBlocks,
|
||||
comments: {
|
||||
"zacus_payload": {
|
||||
blockId: null, x: 20, y: 20, width: 420, height: 320,
|
||||
minimized: false,
|
||||
text: "ZACUS_BLOCKS_YAML_BEGIN\n" + yaml + "\nZACUS_BLOCKS_YAML_END",
|
||||
}
|
||||
},
|
||||
currentCostume: 0,
|
||||
costumes: [{
|
||||
name: "backdrop1",
|
||||
bitmapResolution: 1,
|
||||
dataFormat: "png",
|
||||
assetId: BACKDROP_MD5,
|
||||
md5ext: BACKDROP_MD5 + ".png",
|
||||
rotationCenterX: 0,
|
||||
rotationCenterY: 0,
|
||||
}],
|
||||
sounds: [],
|
||||
volume: 100,
|
||||
layerOrder: 0,
|
||||
tempo: 60,
|
||||
videoTransparency: 50,
|
||||
videoState: "on",
|
||||
textToSpeechLanguage: null,
|
||||
};
|
||||
const project = {
|
||||
targets: [stage],
|
||||
monitors: [],
|
||||
extensions: [],
|
||||
meta: { semver: "3.0.0", vm: "2.3.4", agent: "ZacusHub/0.1" }
|
||||
};
|
||||
const zip = new JSZip();
|
||||
zip.file("project.json", JSON.stringify(project));
|
||||
// backdrop asset
|
||||
const bytes = Uint8Array.from(atob(BACKDROP_PNG_B64), c => c.charCodeAt(0));
|
||||
zip.file(BACKDROP_MD5 + ".png", bytes);
|
||||
return zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||
}
|
||||
|
||||
// ---------- .sb3 import ----------
|
||||
|
||||
async function sb3ToWorkspaceXml(arrayBuffer) {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
const projFile = zip.file("project.json");
|
||||
if (!projFile) throw new Error("project.json missing");
|
||||
const project = JSON.parse(await projFile.async("string"));
|
||||
// Prefer round-trip via the embedded comment, if present
|
||||
for (const t of (project.targets || [])) {
|
||||
for (const c of Object.values(t.comments || {})) {
|
||||
const m = /ZACUS_BLOCKS_YAML_BEGIN\n([\s\S]*?)\nZACUS_BLOCKS_YAML_END/.exec(c.text || "");
|
||||
if (m) return yamlToWorkspaceXml(m[1]);
|
||||
}
|
||||
}
|
||||
// Fallback: parse procedures_call entries with proccode zacus.<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,
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user